claude-dev-env 8.69.0 → 8.70.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 (99) hide show
  1. package/.agents/skills/correction/SKILL.md +28 -6
  2. package/.agents/skills/correction/references/filing.md +33 -0
  3. package/.agents/skills/correction/scripts/correction_filing.py +327 -0
  4. package/.agents/skills/correction/scripts/correction_filing_constants/__init__.py +0 -0
  5. package/.agents/skills/correction/scripts/correction_filing_constants/config/__init__.py +0 -0
  6. package/.agents/skills/correction/scripts/correction_filing_constants/config/constants.py +56 -0
  7. package/.agents/skills/correction/scripts/pytest.ini +4 -0
  8. package/.agents/skills/correction/scripts/test_correction_filing.py +155 -0
  9. package/.agents/skills/pr-lifecycle/SKILL.md +3 -1
  10. package/.agents/skills/second-account-workers/SKILL.md +21 -3
  11. package/docs/rule-guides/features-start-with-an-eval.md +40 -0
  12. package/hooks/blocking/followup_pr_dedupe.py +55 -5
  13. package/hooks/blocking/image_read_size_gate.py +167 -0
  14. package/hooks/blocking/pr_lifecycle_skill_gate.py +62 -11
  15. package/hooks/blocking/pull_request_proof.py +86 -20
  16. package/hooks/blocking/test_artifact_look_gate.py +1 -0
  17. package/hooks/blocking/test_followup_pr_dedupe.py +33 -0
  18. package/hooks/blocking/test_image_read_size_gate.py +79 -0
  19. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +117 -3
  20. package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -5
  21. package/hooks/blocking/test_pull_request_proof.py +124 -1
  22. package/hooks/features/README.md +1 -0
  23. package/hooks/features/conduct-gates.md +1 -1
  24. package/hooks/features/read-gates.md +20 -0
  25. package/hooks/features/write-edit-blocking.md +3 -5
  26. package/hooks/hooks.json +11 -1
  27. package/hooks/hooks_constants/artifact_look_gate_constants.py +1 -2
  28. package/hooks/hooks_constants/followup_pr_dedupe_constants.py +4 -0
  29. package/hooks/hooks_constants/image_read_size_gate_constants.py +41 -0
  30. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +3 -0
  31. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -5
  32. package/hooks/hooks_constants/pull_request_proof_constants.py +20 -1
  33. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +4 -11
  34. package/hooks/hooks_constants/transcript_skill_scan_constants.py +1 -0
  35. package/hooks/test_transcript_skill_scan.py +75 -1
  36. package/hooks/transcript_skill_scan.py +87 -15
  37. package/package.json +1 -1
  38. package/rules/anti-corollary-tests.md +2 -2
  39. package/rules/archiving-agent-config.md +2 -2
  40. package/rules/asd-ste100-language.md +2 -2
  41. package/rules/bdd.md +1 -1
  42. package/rules/build-needs-a-user.md +1 -1
  43. package/rules/cleanup-temp-files.md +3 -3
  44. package/rules/code-standards.md +1 -1
  45. package/rules/correction-lens.md +3 -3
  46. package/rules/destructive-commands.md +3 -3
  47. package/rules/doc-inventory-integrity.md +1 -1
  48. package/rules/docstring-prose-matches-implementation.md +1 -1
  49. package/rules/explore-thoroughly.md +1 -1
  50. package/rules/failure-blast-radius.md +1 -1
  51. package/rules/falsify-before-green.md +2 -2
  52. package/rules/features-start-with-an-eval.md +9 -0
  53. package/rules/filesystem-search.md +3 -3
  54. package/rules/flag-non-breaking-findings.md +2 -2
  55. package/rules/long-horizon-autonomy.md +2 -2
  56. package/rules/memory-stores-durable-facts.md +2 -2
  57. package/rules/no-contrast-framing.md +1 -1
  58. package/rules/no-cross-skill-duplicate-helpers.md +1 -1
  59. package/rules/orphan-css-class.md +1 -1
  60. package/rules/paired-test-coverage.md +1 -1
  61. package/rules/plain-illustrative-docstrings.md +1 -1
  62. package/rules/prefer-existing-tools.md +2 -2
  63. package/rules/proof-before-pull-request.md +1 -1
  64. package/rules/request-scope.md +1 -1
  65. package/rules/research-mode.md +2 -2
  66. package/rules/retired-hook-prose.md +1 -1
  67. package/rules/session-title.md +1 -1
  68. package/rules/shell-invocation.md +2 -2
  69. package/rules/testing.md +1 -1
  70. package/rules/verify-before-asking.md +3 -3
  71. package/rules/verify-runtime-state.md +2 -2
  72. package/rules/windows-filesystem-safe.md +1 -1
  73. package/rules/workers-done-before-complete.md +2 -2
  74. package/rules/workflow-substitution-slots.md +1 -1
  75. package/scripts/account_broker.py +10 -3
  76. package/scripts/account_broker_support.py +7 -3
  77. package/scripts/agent_image_copy.py +155 -0
  78. package/scripts/claude_account_worker.py +24 -5
  79. package/scripts/claude_account_worker_process.py +5 -0
  80. package/scripts/claude_account_worker_report.py +16 -1
  81. package/scripts/dev_env_scripts_constants/agent_image_copy_constants.py +11 -0
  82. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +12 -0
  83. package/scripts/policy_lint/config/constants.py +1 -0
  84. package/scripts/test_account_broker.py +38 -0
  85. package/scripts/test_account_broker_support.py +24 -0
  86. package/scripts/test_agent_image_copy.py +76 -0
  87. package/scripts/test_claude_account_worker.py +47 -0
  88. package/scripts/test_claude_account_worker_process.py +20 -0
  89. package/scripts/test_claude_account_worker_report.py +15 -0
  90. package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +4 -4
  91. package/scripts/tests/test_rule_load_scopes.py +1 -0
  92. package/hooks/advisory/conftest.py +0 -10
  93. package/hooks/advisory/refactor_guard.py +0 -385
  94. package/hooks/advisory/refactor_guard_test_support.py +0 -46
  95. package/hooks/advisory/test_refactor_guard.py +0 -148
  96. package/hooks/advisory/test_refactor_guard_advisory.py +0 -242
  97. package/hooks/advisory/test_refactor_guard_eligibility.py +0 -166
  98. package/hooks/hooks_constants/refactor_guard_constants.py +0 -77
  99. package/hooks/hooks_constants/test_refactor_guard_constants.py +0 -21
@@ -4,6 +4,6 @@
4
4
 
5
5
  Keep only a fact that helps a fresh session weeks later. Remove dates, task identifiers, commits, and paths from a draft; if the remainder is empty or false, put it in the task tracker or handoff instead. Store standing user preferences, stable setup traps, and useful resource pointers. Delete stale memory and its index line in the same run.
6
6
 
7
- **Enforcement:** none, the agent applies it.
7
+ **Enforcement:** none.
8
8
 
9
- **Full text:** [`docs/rule-guides/memory-stores-durable-facts.md`](../docs/rule-guides/memory-stores-durable-facts.md). Read it when deciding whether a fact belongs in memory.
9
+ **Full text:** [guide](../docs/rule-guides/memory-stores-durable-facts.md). Read it when deciding whether a fact belongs in memory.
@@ -6,4 +6,4 @@ State the chosen point directly and leave out the discarded reading. Remove thes
6
6
 
7
7
  **Enforcement:** staged policy lint and `scripts/durable_post_lint.py` check authored text; the agent checks chat.
8
8
 
9
- **Full text:** [`docs/rule-guides/no-contrast-framing.md`](../docs/rule-guides/no-contrast-framing.md). Read it for examples and checker exceptions.
9
+ **Full text:** [guide](../docs/rule-guides/no-contrast-framing.md). Read it for examples and checker exceptions.
@@ -10,4 +10,4 @@ Within one skill, extract a shared module and import it at both call sites. Acro
10
10
 
11
11
  **Enforcement:** `code_rules_duplicate_body.py` blocks sibling-module copies and emits cross-skill advisories through `code_rules_enforcer.py`; the agent judges copy size.
12
12
 
13
- **Full text:** [`docs/rule-guides/no-cross-skill-duplicate-helpers.md`](../docs/rule-guides/no-cross-skill-duplicate-helpers.md). Read it when judging copy size or dependency ownership.
13
+ **Full text:** [guide](../docs/rule-guides/no-cross-skill-duplicate-helpers.md). Read it when judging copy size or dependency ownership.
@@ -11,4 +11,4 @@ Every class the markup references has a `.<class>` selector in a nearby `<style>
11
11
 
12
12
  **Enforcement:** `check_orphan_css_classes` in `code_rules_orphan_css_class.py`, which the staged policy lint runs through `code_rules_enforcer.py`.
13
13
 
14
- **Full text:** [`docs/rule-guides/orphan-css-class.md`](../docs/rule-guides/orphan-css-class.md).
14
+ **Full text:** [guide](../docs/rule-guides/orphan-css-class.md).
@@ -11,4 +11,4 @@ Every public function such a module defines gets a behavioral test in its paired
11
11
 
12
12
  **Enforcement:** `check_public_function_missing_paired_test` and `check_test_file_omits_module_public_function` in `code_rules_paired_test.py`, which the staged policy lint runs through `code_rules_enforcer.py`. Both record smells per [`flag-non-breaking-findings.md`](flag-non-breaking-findings.md).
13
13
 
14
- **Full text:** [`docs/rule-guides/paired-test-coverage.md`](../docs/rule-guides/paired-test-coverage.md).
14
+ **Full text:** [guide](../docs/rule-guides/paired-test-coverage.md).
@@ -11,4 +11,4 @@ Write the narrative so a general developer follows it on the first read. Paint a
11
11
 
12
12
  **Enforcement:** `check_docstring_runon_sentence` and `check_docstring_prose_wall_without_illustration` in `code_rules_docstrings.py`, which the staged policy lint runs through `code_rules_enforcer.py`. Category O sub-bucket O9 of the audit rubric carries the judgment.
13
13
 
14
- **Full text:** [`docs/rule-guides/plain-illustrative-docstrings.md`](../docs/rule-guides/plain-illustrative-docstrings.md), with the canonical example.
14
+ **Full text:** [guide](../docs/rule-guides/plain-illustrative-docstrings.md), with the canonical example.
@@ -17,6 +17,6 @@ paths:
17
17
 
18
18
  Search local and shared code, then open-source options. Stop at first fit; build if neither fits. Choose an external option with an active security process, no open critical advisory, field reputation, broad use, and a release or commit within a year; add missing rules in its configuration without a wrapper. Report its name, link, license, and one use count; for custom code, name rejected candidates and reasons.
19
19
 
20
- **Enforcement:** none, the agent applies it.
20
+ **Enforcement:** none.
21
21
 
22
- **Full text:** [`docs/rule-guides/prefer-existing-tools.md`](../docs/rule-guides/prefer-existing-tools.md). Read it when comparing candidates.
22
+ **Full text:** [guide](../docs/rule-guides/prefer-existing-tools.md). Read it when comparing candidates.
@@ -4,4 +4,4 @@
4
4
 
5
5
  Run the change where it runs, with and without it, and quote both results under "Proof in practice" in the body. A hook denies a body without one. Unproven: leave it unopened and tell the user.
6
6
 
7
- **Full text:** [`docs/rule-guides/proof-before-pull-request.md`](../docs/rule-guides/proof-before-pull-request.md).
7
+ **Full text:** [guide](../docs/rule-guides/proof-before-pull-request.md).
@@ -4,4 +4,4 @@
4
4
 
5
5
  A shipped gate makes each block line restating what it enforces dead text. Propose removing all of them, past the part asked about, and give each kept line's reason. Read the output back before reporting done.
6
6
 
7
- **Full text:** [`docs/rule-guides/request-scope.md`](../docs/rule-guides/request-scope.md)
7
+ **Full text:** [guide](../docs/rule-guides/request-scope.md)
@@ -4,6 +4,6 @@
4
4
 
5
5
  State no claim without a credible source; do not guess or infer. Before an answer depends on an unsettled fact, run each tool that could settle it and that reads state or that permissions already allow, and stop at the first one that settles it. When none can, name the exact check and who can run or approve it. Cite a project file, web source, named expert or paper, or official documentation for each claim. Retract claims a source cannot support. Extract document text before analysis and ground the answer in word-for-word quotes. In chat, cite compactly with a link or `file:line`; put full quotes and citation lists in artifacts or replies that request them. Ground synthesis in sourced inputs. Creative ideas need no citation.
6
6
 
7
- **Enforcement:** none, the agent applies it.
7
+ **Enforcement:** none.
8
8
 
9
- **Full text:** [`docs/rule-guides/research-mode.md`](../docs/rule-guides/research-mode.md). Read it when checking a source or citation.
9
+ **Full text:** [guide](../docs/rule-guides/research-mode.md). Read it when checking a source or citation.
@@ -11,4 +11,4 @@ Verify the module and its hook registration or dispatcher roster before claiming
11
11
 
12
12
  **Enforcement:** `retired-hook-prose` in `scripts/policy_lint/registry.py` checks instruction Markdown, including `rules/` and `docs/`; the detour sweep is manual.
13
13
 
14
- **Full text:** [`docs/rule-guides/retired-hook-prose.md`](../docs/rule-guides/retired-hook-prose.md). Read it when tracing a hook's current path or removing its gate.
14
+ **Full text:** [guide](../docs/rule-guides/retired-hook-prose.md). Read it when tracing a hook's current path or removing its gate.
@@ -6,4 +6,4 @@ Before each turn ends, set the title to `<emoji> <name>`. Use the red flag when
6
6
 
7
7
  **Enforcement:** `hooks/blocking/session_title_format_gate.py` denies a malformed title; `hooks/blocking/session_title_stop_gate.py` blocks a turn end with no title.
8
8
 
9
- **Full text:** [`docs/rule-guides/session-title.md`](../docs/rule-guides/session-title.md). Read it for the emoji table and naming rules.
9
+ **Full text:** [guide](../docs/rule-guides/session-title.md). Read it for the emoji table and naming rules.
@@ -4,6 +4,6 @@
4
4
 
5
5
  On Windows, run shell work through `pwsh`: `pwsh -NoProfile -File '<script>.ps1' <args>` for a script, and `pwsh -NoProfile -Command "..."` or the `PowerShell` tool for inline work. A direct interpreter call such as `python script.py` conforms as written. Keep `powershell`, `cmd /c`, and `bash -c` out of permission rules. Keep `$(...)`, unescaped backticks, and `<(...)` or `>(...)` out of Bash commands; split the call or use a flag form such as `git -C "<path>"`. Arithmetic `$((...))` passes.
6
6
 
7
- **Enforcement:** none, the agent applies it; a permission prompt on a wrapped command is the signal. `blocking/msys_rev_path_rewriter.py` only rewrites a `<rev>:<path>` argument.
7
+ **Enforcement:** none; a permission prompt on a wrapped command is the signal. `blocking/msys_rev_path_rewriter.py` only rewrites a `<rev>:<path>` argument.
8
8
 
9
- **Full text:** [`docs/rule-guides/shell-invocation.md`](../docs/rule-guides/shell-invocation.md). Read it before writing a permission rule or a multi-step shell command.
9
+ **Full text:** [guide](../docs/rule-guides/shell-invocation.md). Read it before writing a permission rule or a multi-step shell command.
package/rules/testing.md CHANGED
@@ -16,4 +16,4 @@ Read [`TEST_QUALITY.md`](../docs/TEST_QUALITY.md) and give mocks every field the
16
16
 
17
17
  **Enforcement:** `useless_test_checks.py` and `code_rules_test_assertions.py` catch weak assertions; `Fix test proof` checks fix tests.
18
18
 
19
- **Full text:** [`docs/rule-guides/testing.md`](../docs/rule-guides/testing.md). Read it for mock fields, test order, and fix proof details.
19
+ **Full text:** [guide](../docs/rule-guides/testing.md). Read it for mock fields, test order, and fix proof details.
@@ -2,8 +2,8 @@
2
2
 
3
3
  **When:** Before asking the user a clarifying question.
4
4
 
5
- Inspect files, directories, configuration, environment, databases, and available tools for the answer. Recheck facts recalled from earlier sessions. Apply any criterion the user already supplied and state the decision. Ask only for a judgment, preference, or inaccessible fact; use the session's question tool when available.
5
+ Inspect files, directories, configuration, environment, databases, and available tools for the answer. Recheck facts recalled from earlier sessions. Apply any criterion the user already supplied and state the decision. Ask only for a judgment, preference, or inaccessible fact.
6
6
 
7
- **Enforcement:** none, the agent applies it.
7
+ **Enforcement:** none.
8
8
 
9
- **Full text:** [`docs/rule-guides/verify-before-asking.md`](../docs/rule-guides/verify-before-asking.md). Read it when the source of an answer is uncertain.
9
+ **Full text:** [guide](../docs/rule-guides/verify-before-asking.md). Read it when the source of an answer is uncertain.
@@ -4,6 +4,6 @@
4
4
 
5
5
  Gather a live signal this session: process list, port probe, log, status code, or fresh reproduction. Read the user's terminal and named error log while a reported failure is still visible. Check the effect the work should produce, including each job result, loaded config, or deployed artifact; a success status alone does not establish the effect. When testing code, identify the loaded module path.
6
6
 
7
- **Enforcement:** none, the agent applies it.
7
+ **Enforcement:** none.
8
8
 
9
- **Full text:** [`docs/rule-guides/verify-runtime-state.md`](../docs/rule-guides/verify-runtime-state.md). Read it when choosing the probe for a claim.
9
+ **Full text:** [guide](../docs/rule-guides/verify-runtime-state.md). Read it when choosing the probe for a claim.
@@ -14,4 +14,4 @@ Call `shutil.rmtree` with an `onexc` (Python 3.12 and later) or `onerror` handle
14
14
 
15
15
  **Enforcement:** the staged policy lint's `rmtree-safety` rule, which returns the full `force_rmtree` code. CI runs it against the merge base.
16
16
 
17
- **Full text:** [`docs/rule-guides/windows-filesystem-safe.md`](../docs/rule-guides/windows-filesystem-safe.md).
17
+ **Full text:** [guide](../docs/rule-guides/windows-filesystem-safe.md).
@@ -10,6 +10,6 @@ paths:
10
10
 
11
11
  List every worker and confirm that each has finished and its result is merged into run state. Verify worker file lists, counts, descriptions, and findings against the repository and diff before repeating them. While a worker runs or output is missing, keep the task `in_progress`, report dead or hung workers, schedule a wakeup, and keep other work moving. Check the task goal against merged state before closing.
12
12
 
13
- **Enforcement:** none, the agent applies it.
13
+ **Enforcement:** none.
14
14
 
15
- **Full text:** [`docs/rule-guides/workers-done-before-complete.md`](../docs/rule-guides/workers-done-before-complete.md). Read it before closing a task that spawned workers.
15
+ **Full text:** [guide](../docs/rule-guides/workers-done-before-complete.md). Read it before closing a task that spawned workers.
@@ -11,4 +11,4 @@ Mark each changing value with angle brackets, including loop indices in paths or
11
11
 
12
12
  **Enforcement:** `workflow-substitution` in the staged policy lint.
13
13
 
14
- **Full text:** [`docs/rule-guides/workflow-substitution-slots.md`](../docs/rule-guides/workflow-substitution-slots.md). Read it when a loop builds a path or output key.
14
+ **Full text:** [guide](../docs/rule-guides/workflow-substitution-slots.md). Read it when a loop builds a path or output key.
@@ -216,6 +216,7 @@ class _RunContext:
216
216
  all_spent_accounts: set[Account]
217
217
  all_spent_resets: dict[Account, datetime]
218
218
  start_failure_text: str = ""
219
+ live_log: Path | None = None
219
220
 
220
221
 
221
222
  def _prepare_run(
@@ -269,6 +270,7 @@ def _invoke(context: _RunContext, account: Account) -> subprocess.CompletedProce
269
270
  cwd=str(context.cwd) if context.cwd is not None else None,
270
271
  encoding=context.encoding,
271
272
  errors=context.errors,
273
+ live_log=context.live_log,
272
274
  )
273
275
 
274
276
 
@@ -317,9 +319,11 @@ def _attempt_once(context: _RunContext, decision: Decision) -> JobOutcome | None
317
319
  def _execute(
318
320
  product: Product, all_argv: Sequence[str], *, now: datetime,
319
321
  timeout_seconds: float | None = None, stdin_text: str | bytes | None = None,
320
- cwd: str | Path | None = None, encoding: str = "utf-8", errors: str = "replace"
322
+ cwd: str | Path | None = None, encoding: str = "utf-8", errors: str = "replace",
323
+ live_log: Path | None = None,
321
324
  ) -> tuple[JobOutcome, Report]:
322
325
  context = _prepare_run(product, all_argv, now, timeout_seconds, stdin_text, cwd, encoding, errors)
326
+ context.live_log = live_log
323
327
  while True:
324
328
  picked = choose_from_readings(product, context.all_readings, now=now, all_spent_accounts=frozenset(context.all_spent_accounts), preferred_command=context.preferred_command, all_spent_resets=context.all_spent_resets)
325
329
  decision = _with_outside_spent_marks(picked, _outside_roster_resets(product, context.all_readings, context.all_state, now))
@@ -340,8 +344,9 @@ def run_job(
340
344
  cwd: str | Path | None = None,
341
345
  encoding: str = "utf-8",
342
346
  errors: str = "replace",
347
+ live_log: Path | None = None,
343
348
  ) -> JobOutcome:
344
- return _execute(product, all_argv, now=datetime.now(timezone.utc), timeout_seconds=timeout_seconds, stdin_text=stdin_text, cwd=cwd, encoding=encoding, errors=errors)[0]
349
+ return _execute(product, all_argv, now=datetime.now(timezone.utc), timeout_seconds=timeout_seconds, stdin_text=stdin_text, cwd=cwd, encoding=encoding, errors=errors, live_log=live_log)[0]
345
350
 
346
351
 
347
352
  def _parser() -> argparse.ArgumentParser:
@@ -363,7 +368,9 @@ def _parser() -> argparse.ArgumentParser:
363
368
 
364
369
 
365
370
  def _parse_spent_mark(all_accounts_by_name: Mapping[str, Reading], mark: str, product: Product) -> tuple[Reading, datetime | None]:
366
- name, separator, reset_text = mark.partition(":")
371
+ name, separator, reset_text = mark.rpartition(":")
372
+ if mark in all_accounts_by_name or not separator:
373
+ name, separator, reset_text = mark, "", ""
367
374
  if not name:
368
375
  raise BrokerConfigurationError(f"spent mark {mark!r} needs an account name")
369
376
  try:
@@ -14,7 +14,7 @@ import tempfile
14
14
  import threading
15
15
  from contextlib import contextmanager
16
16
  from datetime import datetime, timezone
17
- from pathlib import Path
17
+ from pathlib import Path, PureWindowsPath
18
18
  from typing import Callable, Iterator, Sequence
19
19
 
20
20
  if sys.platform == "win32":
@@ -147,7 +147,9 @@ def _account_order(main_home: Path) -> tuple[str, ...]:
147
147
 
148
148
  def _order_names(account: Account) -> frozenset[str]:
149
149
  launcher_name = Path(CLAUDE_LAUNCHER_PROGRAM.file_name_template.format(profile_name=account.name)).stem
150
- return frozenset(each_name.casefold() for each_name in (account.name, account.command, launcher_name) if each_name)
150
+ all_candidates = (account.name, account.command, launcher_name)
151
+ all_launcher_stems = tuple(PureWindowsPath(each_name).stem for each_name in all_candidates if each_name)
152
+ return frozenset(each_name.casefold() for each_name in (*all_candidates, *all_launcher_stems) if each_name)
151
153
 
152
154
 
153
155
  def _with_priorities(all_accounts: Sequence[Account], all_ordered_names: Sequence[str]) -> tuple[Account, ...]:
@@ -509,7 +511,9 @@ def _run_captured_subprocess(all_argv: Sequence[str], **options: object) -> subp
509
511
  encoding = str(options.get("encoding") or "utf-8")
510
512
  errors = str(options.get("errors") or "replace")
511
513
  stdin_bytes = options.get("input")
512
- with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
514
+ live_log = options.get("live_log")
515
+ stdout_target = open(live_log, "w+b") if live_log is not None else tempfile.TemporaryFile()
516
+ with stdout_target as stdout_file, tempfile.TemporaryFile() as stderr_file:
513
517
  with subprocess.Popen(
514
518
  _resolve_command(all_argv),
515
519
  stdin=subprocess.PIPE if stdin_bytes is not None else None,
@@ -0,0 +1,155 @@
1
+ """Write a copy of an image small enough for an agent to read.
2
+
3
+ The image_read_size_gate hook refuses a Read of an image whose long edge is over
4
+ 512 pixels and names this script. The script writes ``<name>.agent.png`` next to
5
+ the source, scaled so the long edge is 512 pixels with the aspect ratio kept,
6
+ and prints its path::
7
+
8
+ python3 agent_image_copy.py shot.png -> shot.agent.png (288x512)
9
+ python3 agent_image_copy.py shot.png --crop 0,0,720,640
10
+ -> the top-left region, capped
11
+ python3 agent_image_copy.py icon.png (200x200) -> icon.agent.png, same size
12
+
13
+ The copy is newer than the source, so a check that wants a screenshot read
14
+ after a page changed still counts it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import math
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ scripts_directory = str(Path(__file__).resolve().parent)
25
+ if scripts_directory not in sys.path:
26
+ sys.path.insert(0, scripts_directory)
27
+
28
+ from dev_env_scripts_constants.agent_image_copy_constants import (
29
+ AGENT_COPY_SUFFIX,
30
+ ALL_PNG_WRITABLE_MODES,
31
+ CROP_BOX_PART_COUNT,
32
+ CROP_BOX_SEPARATOR,
33
+ MAXIMUM_LONG_EDGE_PIXELS,
34
+ PNG_FALLBACK_MODE,
35
+ VISUAL_TOKEN_PATCH_PIXELS,
36
+ )
37
+ from PIL import Image, ImageOps
38
+
39
+
40
+ def capped_size(
41
+ width: int, height: int, maximum_edge: int = MAXIMUM_LONG_EDGE_PIXELS
42
+ ) -> tuple[int, int]:
43
+ """Return the size that fits the long edge into maximum_edge with the aspect ratio kept.
44
+
45
+ ::
46
+
47
+ capped_size(1440, 2560) -> (288, 512)
48
+ capped_size(2000, 1000) -> (512, 256)
49
+ capped_size(300, 200) -> (300, 200)
50
+
51
+ Args:
52
+ width: Source width in pixels.
53
+ height: Source height in pixels.
54
+ maximum_edge: The largest long edge allowed.
55
+
56
+ Returns:
57
+ The capped (width, height).
58
+ """
59
+ long_edge = max(width, height)
60
+ if long_edge <= maximum_edge:
61
+ return width, height
62
+ scale = maximum_edge / long_edge
63
+ return max(1, round(width * scale)), max(1, round(height * scale))
64
+
65
+
66
+ def visual_tokens(width: int, height: int) -> int:
67
+ """Return the documented visual-token cost of an image: one token per 28x28 patch.
68
+
69
+ ::
70
+
71
+ visual_tokens(1440, 2560) -> 4784
72
+ visual_tokens(288, 512) -> 209
73
+
74
+ Args:
75
+ width: Image width in pixels as the model sees it.
76
+ height: Image height in pixels as the model sees it.
77
+ """
78
+ return math.ceil(width / VISUAL_TOKEN_PATCH_PIXELS) * math.ceil(
79
+ height / VISUAL_TOKEN_PATCH_PIXELS
80
+ )
81
+
82
+
83
+ def parse_crop_box(crop_text: str) -> tuple[int, int, int, int]:
84
+ """Return LEFT, TOP, RIGHT, BOTTOM parsed from text like ``0,0,720,640``.
85
+
86
+ Args:
87
+ crop_text: Four comma-separated pixel coordinates.
88
+
89
+ Returns:
90
+ The four coordinates as integers.
91
+
92
+ Raises:
93
+ argparse.ArgumentTypeError: When the text does not hold four numbers.
94
+ """
95
+ all_parts = crop_text.split(CROP_BOX_SEPARATOR)
96
+ if len(all_parts) != CROP_BOX_PART_COUNT:
97
+ raise argparse.ArgumentTypeError(
98
+ f"--crop takes LEFT,TOP,RIGHT,BOTTOM, got {crop_text!r}"
99
+ )
100
+ left, top, right, bottom = (int(each_part) for each_part in all_parts)
101
+ return left, top, right, bottom
102
+
103
+
104
+ def write_agent_copy(
105
+ source_path: Path, all_crop_coordinates: tuple[int, int, int, int] | None
106
+ ) -> Path:
107
+ """Write the capped copy beside the source and return its path.
108
+
109
+ The copy is turned upright by its EXIF orientation before the crop, and a mode PNG
110
+ cannot hold, such as CMYK, is converted to RGB.
111
+
112
+ Args:
113
+ source_path: The full-size image.
114
+ all_crop_coordinates: A LEFT, TOP, RIGHT, BOTTOM region cut before scaling, or None for the whole image.
115
+
116
+ Returns:
117
+ The path of the written copy.
118
+ """
119
+ copy_path = source_path.with_name(source_path.stem + AGENT_COPY_SUFFIX)
120
+ with Image.open(source_path) as source_image:
121
+ upright_image = ImageOps.exif_transpose(source_image)
122
+ working_image = upright_image.crop(all_crop_coordinates) if all_crop_coordinates else upright_image.copy()
123
+ if working_image.mode not in ALL_PNG_WRITABLE_MODES:
124
+ working_image = working_image.convert(PNG_FALLBACK_MODE)
125
+ working_image = working_image.resize(
126
+ capped_size(*working_image.size), Image.Resampling.LANCZOS
127
+ )
128
+ working_image.save(copy_path, optimize=True)
129
+ return copy_path
130
+
131
+
132
+ def main(all_arguments: list[str]) -> int:
133
+ """Write the capped copy named on the command line and print its path and token cost.
134
+
135
+ Args:
136
+ all_arguments: Command-line arguments after the program name.
137
+
138
+ Returns:
139
+ 0 when the copy was written.
140
+ """
141
+ parser = argparse.ArgumentParser(
142
+ description="Write an agent-size copy of an image."
143
+ )
144
+ parser.add_argument("image_path", type=Path)
145
+ parser.add_argument("--crop", type=parse_crop_box, default=None)
146
+ parsed = parser.parse_args(all_arguments)
147
+ copy_path = write_agent_copy(parsed.image_path, parsed.crop)
148
+ with Image.open(copy_path) as copy_image:
149
+ width, height = copy_image.size
150
+ print(f"{copy_path} {width}x{height} about {visual_tokens(width, height)} tokens")
151
+ return 0
152
+
153
+
154
+ if __name__ == "__main__":
155
+ sys.exit(main(sys.argv[1:]))
@@ -19,15 +19,19 @@ from dev_env_scripts_constants.claude_account_worker_constants import (
19
19
  DEFAULT_TIMEOUT_MINUTES,
20
20
  INVALID_TIMEOUT_MESSAGE,
21
21
  LAUNCH_FAILURE_EXIT_CODE,
22
+ LIVE_LOG_FLAG,
22
23
  MINIMUM_TIMEOUT_MINUTES,
23
24
  MODEL_FLAG,
24
25
  OUTPUT_FORMAT_FLAG,
25
26
  OUTPUT_FORMAT_JSON,
27
+ OUTPUT_FORMAT_STREAM_JSON,
26
28
  PERMISSION_MODE_FLAG,
27
29
  PROMPT_FILE_FLAG,
28
30
  REPORT_FILE_FLAG,
29
31
  SINGLE_PROMPT_FLAG,
30
32
  TIMEOUT_MINUTES_FLAG,
33
+ UNWRITABLE_LIVE_LOG_MESSAGE,
34
+ VERBOSE_FLAG,
31
35
  UTF8_ENCODING,
32
36
  )
33
37
 
@@ -50,15 +54,16 @@ def build_argument_parser() -> argparse.ArgumentParser:
50
54
  parser.add_argument(MODEL_FLAG)
51
55
  parser.add_argument(PERMISSION_MODE_FLAG, default=DEFAULT_PERMISSION_MODE)
52
56
  parser.add_argument(TIMEOUT_MINUTES_FLAG, type=_positive_timeout_minutes, default=DEFAULT_TIMEOUT_MINUTES)
57
+ parser.add_argument(LIVE_LOG_FLAG, type=Path)
53
58
  return parser
54
59
 
55
60
 
56
- def _invocation(*, model: str | None, permission_mode: str) -> list[str]:
61
+ def _invocation(*, model: str | None, permission_mode: str, is_streamed: bool) -> list[str]:
62
+ format_flags = [OUTPUT_FORMAT_FLAG, OUTPUT_FORMAT_STREAM_JSON, VERBOSE_FLAG] if is_streamed else [OUTPUT_FORMAT_FLAG, OUTPUT_FORMAT_JSON]
57
63
  arguments = [
58
64
  CLAUDE_BINARY_NAME,
59
65
  SINGLE_PROMPT_FLAG,
60
- OUTPUT_FORMAT_FLAG,
61
- OUTPUT_FORMAT_JSON,
66
+ *format_flags,
62
67
  PERMISSION_MODE_FLAG,
63
68
  permission_mode,
64
69
  ]
@@ -81,20 +86,33 @@ def run_worker(
81
86
  model: str | None,
82
87
  permission_mode: str,
83
88
  timeout_minutes: int,
89
+ live_log: Path | None = None,
84
90
  ) -> int:
85
- """Run one worker and write its structured report."""
91
+ """Run one worker and write its structured report.
92
+
93
+ With ``live_log`` set, the worker streams one JSON event per line into that
94
+ file while it runs, so a watching session can read progress before the end.
95
+ A live log that cannot be written ends the run before the broker starts.
96
+ """
86
97
  try:
87
98
  prompt_text = prompt_file.read_text(encoding=UTF8_ENCODING)
88
99
  except (OSError, UnicodeError):
89
100
  report = pre_launch_failure_report("none", "prompt file is unreadable", LAUNCH_FAILURE_EXIT_CODE)
90
101
  return finalize_report(report_file, report)
102
+ if live_log is not None:
103
+ try:
104
+ live_log.write_bytes(b"")
105
+ except OSError:
106
+ report = pre_launch_failure_report("none", UNWRITABLE_LIVE_LOG_MESSAGE, LAUNCH_FAILURE_EXIT_CODE)
107
+ return finalize_report(report_file, report)
91
108
  try:
92
109
  outcome, duration_seconds = invoke_worker(
93
- all_arguments=_invocation(model=model, permission_mode=permission_mode),
110
+ all_arguments=_invocation(model=model, permission_mode=permission_mode, is_streamed=live_log is not None),
94
111
  cwd=cwd,
95
112
  prompt_text=prompt_text,
96
113
  timeout_minutes=timeout_minutes,
97
114
  runner=worker_job_runner,
115
+ live_log=live_log,
98
116
  )
99
117
  except BrokerConfigurationError as error:
100
118
  report = pre_launch_failure_report("none", str(error), CONFIGURATION_FAILURE_EXIT_CODE)
@@ -122,6 +140,7 @@ def main() -> int:
122
140
  model=arguments.model,
123
141
  permission_mode=arguments.permission_mode,
124
142
  timeout_minutes=arguments.timeout_minutes,
143
+ live_log=arguments.live_log,
125
144
  )
126
145
 
127
146
 
@@ -17,6 +17,7 @@ def invoke_worker(
17
17
  prompt_text: str,
18
18
  timeout_minutes: int,
19
19
  runner: Callable[..., JobOutcome],
20
+ live_log: Path | None = None,
20
21
  monotonic_clock: Callable[[], float] = time.monotonic,
21
22
  ) -> tuple[JobOutcome, float]:
22
23
  """Run one broker job and measure its elapsed time.
@@ -28,12 +29,15 @@ def invoke_worker(
28
29
  timeout_minutes: Job time limit in minutes; the runner receives it in seconds.
29
30
  runner: Broker job runner, called with ``Product.CLAUDE``,
30
31
  ``encoding="utf-8"``, and ``errors="replace"``.
32
+ live_log: File the runner writes the job's standard output into as it
33
+ runs, passed only when set.
31
34
  monotonic_clock: Clock read once before and once after the job.
32
35
 
33
36
  Returns:
34
37
  The runner's job outcome and the difference between the two clock reads.
35
38
  """
36
39
  started_at = monotonic_clock()
40
+ all_log_options = {} if live_log is None else {"live_log": live_log}
37
41
  outcome = runner(
38
42
  Product.CLAUDE,
39
43
  all_arguments,
@@ -42,5 +46,6 @@ def invoke_worker(
42
46
  cwd=cwd,
43
47
  encoding="utf-8",
44
48
  errors="replace",
49
+ **all_log_options,
45
50
  )
46
51
  return outcome, monotonic_clock() - started_at
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
 
10
10
  from dev_env_scripts_constants.claude_account_worker_constants import (
11
11
  DURATION_DECIMAL_PLACES,
12
+ JSON_EVENT_TYPE_KEY,
12
13
  JSON_IS_ERROR_KEY,
13
14
  JSON_RESULT_KEY,
14
15
  REPORT_ACCOUNT_KEY,
@@ -18,6 +19,7 @@ from dev_env_scripts_constants.claude_account_worker_constants import (
18
19
  REPORT_IS_ERROR_KEY,
19
20
  REPORT_REASON_KEY,
20
21
  REPORT_RESULT_KEY,
22
+ RESULT_EVENT_TYPE,
21
23
  STDOUT_TAIL_CHARACTER_LIMIT,
22
24
  SUMMARY_LINE_TEMPLATE,
23
25
  UTF8_ENCODING,
@@ -41,11 +43,24 @@ def _bounded_stdout_tail(stdout_text: str) -> str:
41
43
  return stdout_text[-STDOUT_TAIL_CHARACTER_LIMIT:]
42
44
 
43
45
 
46
+ def _closing_event(stdout_text: str) -> dict[str, object] | None:
47
+ for each_line in reversed(stdout_text.splitlines()):
48
+ try:
49
+ parsed_line = json.loads(each_line)
50
+ except json.JSONDecodeError:
51
+ continue
52
+ if isinstance(parsed_line, dict) and parsed_line.get(JSON_EVENT_TYPE_KEY) == RESULT_EVENT_TYPE:
53
+ return parsed_line
54
+ return None
55
+
56
+
44
57
  def _extract_payload(stdout_text: str) -> tuple[object, bool]:
45
58
  try:
46
59
  parsed_stdout = json.loads(stdout_text)
47
60
  except json.JSONDecodeError:
48
- return _bounded_stdout_tail(stdout_text), True
61
+ parsed_stdout = _closing_event(stdout_text)
62
+ if parsed_stdout is None:
63
+ return _bounded_stdout_tail(stdout_text), True
49
64
  if not isinstance(parsed_stdout, dict) or JSON_RESULT_KEY not in parsed_stdout:
50
65
  return None, True
51
66
  return parsed_stdout[JSON_RESULT_KEY], parsed_stdout.get(JSON_IS_ERROR_KEY) is True
@@ -0,0 +1,11 @@
1
+ """Limits and names for the agent_image_copy script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ MAXIMUM_LONG_EDGE_PIXELS = 512
6
+ VISUAL_TOKEN_PATCH_PIXELS = 28
7
+ AGENT_COPY_SUFFIX = ".agent.png"
8
+ CROP_BOX_SEPARATOR = ","
9
+ CROP_BOX_PART_COUNT = 4
10
+ ALL_PNG_WRITABLE_MODES = frozenset({"1", "L", "LA", "I", "I;16", "P", "RGB", "RGBA"})
11
+ PNG_FALLBACK_MODE = "RGB"
@@ -43,6 +43,16 @@ OUTPUT_FORMAT_FLAG: str = "--output-format"
43
43
 
44
44
  OUTPUT_FORMAT_JSON: str = "json"
45
45
 
46
+ OUTPUT_FORMAT_STREAM_JSON: str = "stream-json"
47
+
48
+ VERBOSE_FLAG: str = "--verbose"
49
+
50
+ LIVE_LOG_FLAG: str = "--live-log"
51
+
52
+ JSON_EVENT_TYPE_KEY: str = "type"
53
+
54
+ RESULT_EVENT_TYPE: str = "result"
55
+
46
56
  SINGLE_PROMPT_FLAG: str = "-p"
47
57
 
48
58
  SECONDS_PER_MINUTE: int = 60
@@ -67,6 +77,8 @@ SUMMARY_LINE_TEMPLATE: str = "account={account} exit_code={exit_code} report={re
67
77
 
68
78
  INVALID_TIMEOUT_MESSAGE: str = "--timeout-minutes must be at least 1"
69
79
 
80
+ UNWRITABLE_LIVE_LOG_MESSAGE: str = "live log file is unwritable"
81
+
70
82
 
71
83
  __all__ = [
72
84
  "CLAUDE_BINARY_NAME",
@@ -71,6 +71,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
71
71
  "blocking/verify_before_acting.py",
72
72
  "blocking/session_title_format_gate.py",
73
73
  "blocking/session_title_stop_gate.py",
74
+ "blocking/image_read_size_gate.py",
74
75
  "blocking/artifact_dark_mode_gate.py",
75
76
  "blocking/artifact_look_gate.py",
76
77
  )