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.
- package/.agents/skills/correction/SKILL.md +28 -6
- package/.agents/skills/correction/references/filing.md +33 -0
- package/.agents/skills/correction/scripts/correction_filing.py +327 -0
- package/.agents/skills/correction/scripts/correction_filing_constants/__init__.py +0 -0
- package/.agents/skills/correction/scripts/correction_filing_constants/config/__init__.py +0 -0
- package/.agents/skills/correction/scripts/correction_filing_constants/config/constants.py +56 -0
- package/.agents/skills/correction/scripts/pytest.ini +4 -0
- package/.agents/skills/correction/scripts/test_correction_filing.py +155 -0
- package/.agents/skills/pr-lifecycle/SKILL.md +3 -1
- package/.agents/skills/second-account-workers/SKILL.md +21 -3
- package/docs/rule-guides/features-start-with-an-eval.md +40 -0
- package/hooks/blocking/followup_pr_dedupe.py +55 -5
- package/hooks/blocking/image_read_size_gate.py +167 -0
- package/hooks/blocking/pr_lifecycle_skill_gate.py +62 -11
- package/hooks/blocking/pull_request_proof.py +86 -20
- package/hooks/blocking/test_artifact_look_gate.py +1 -0
- package/hooks/blocking/test_followup_pr_dedupe.py +33 -0
- package/hooks/blocking/test_image_read_size_gate.py +79 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +117 -3
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -5
- package/hooks/blocking/test_pull_request_proof.py +124 -1
- package/hooks/features/README.md +1 -0
- package/hooks/features/conduct-gates.md +1 -1
- package/hooks/features/read-gates.md +20 -0
- package/hooks/features/write-edit-blocking.md +3 -5
- package/hooks/hooks.json +11 -1
- package/hooks/hooks_constants/artifact_look_gate_constants.py +1 -2
- package/hooks/hooks_constants/followup_pr_dedupe_constants.py +4 -0
- package/hooks/hooks_constants/image_read_size_gate_constants.py +41 -0
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +3 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -5
- package/hooks/hooks_constants/pull_request_proof_constants.py +20 -1
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +4 -11
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +1 -0
- package/hooks/test_transcript_skill_scan.py +75 -1
- package/hooks/transcript_skill_scan.py +87 -15
- package/package.json +1 -1
- package/rules/anti-corollary-tests.md +2 -2
- package/rules/archiving-agent-config.md +2 -2
- package/rules/asd-ste100-language.md +2 -2
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +1 -1
- package/rules/cleanup-temp-files.md +3 -3
- package/rules/code-standards.md +1 -1
- package/rules/correction-lens.md +3 -3
- package/rules/destructive-commands.md +3 -3
- package/rules/doc-inventory-integrity.md +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/explore-thoroughly.md +1 -1
- package/rules/failure-blast-radius.md +1 -1
- package/rules/falsify-before-green.md +2 -2
- package/rules/features-start-with-an-eval.md +9 -0
- package/rules/filesystem-search.md +3 -3
- package/rules/flag-non-breaking-findings.md +2 -2
- package/rules/long-horizon-autonomy.md +2 -2
- package/rules/memory-stores-durable-facts.md +2 -2
- package/rules/no-contrast-framing.md +1 -1
- package/rules/no-cross-skill-duplicate-helpers.md +1 -1
- package/rules/orphan-css-class.md +1 -1
- package/rules/paired-test-coverage.md +1 -1
- package/rules/plain-illustrative-docstrings.md +1 -1
- package/rules/prefer-existing-tools.md +2 -2
- package/rules/proof-before-pull-request.md +1 -1
- package/rules/request-scope.md +1 -1
- package/rules/research-mode.md +2 -2
- package/rules/retired-hook-prose.md +1 -1
- package/rules/session-title.md +1 -1
- package/rules/shell-invocation.md +2 -2
- package/rules/testing.md +1 -1
- package/rules/verify-before-asking.md +3 -3
- package/rules/verify-runtime-state.md +2 -2
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/account_broker.py +10 -3
- package/scripts/account_broker_support.py +7 -3
- package/scripts/agent_image_copy.py +155 -0
- package/scripts/claude_account_worker.py +24 -5
- package/scripts/claude_account_worker_process.py +5 -0
- package/scripts/claude_account_worker_report.py +16 -1
- package/scripts/dev_env_scripts_constants/agent_image_copy_constants.py +11 -0
- package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +12 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +38 -0
- package/scripts/test_account_broker_support.py +24 -0
- package/scripts/test_agent_image_copy.py +76 -0
- package/scripts/test_claude_account_worker.py +47 -0
- package/scripts/test_claude_account_worker_process.py +20 -0
- package/scripts/test_claude_account_worker_report.py +15 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +4 -4
- package/scripts/tests/test_rule_load_scopes.py +1 -0
- package/hooks/advisory/conftest.py +0 -10
- package/hooks/advisory/refactor_guard.py +0 -385
- package/hooks/advisory/refactor_guard_test_support.py +0 -46
- package/hooks/advisory/test_refactor_guard.py +0 -148
- package/hooks/advisory/test_refactor_guard_advisory.py +0 -242
- package/hooks/advisory/test_refactor_guard_eligibility.py +0 -166
- package/hooks/hooks_constants/refactor_guard_constants.py +0 -77
- 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
|
|
7
|
+
**Enforcement:** none.
|
|
8
8
|
|
|
9
|
-
**Full text:** [
|
|
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:** [
|
|
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:** [
|
|
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:** [
|
|
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:** [
|
|
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:** [
|
|
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
|
|
20
|
+
**Enforcement:** none.
|
|
21
21
|
|
|
22
|
-
**Full text:** [
|
|
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:** [
|
|
7
|
+
**Full text:** [guide](../docs/rule-guides/proof-before-pull-request.md).
|
package/rules/request-scope.md
CHANGED
|
@@ -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:** [
|
|
7
|
+
**Full text:** [guide](../docs/rule-guides/request-scope.md)
|
package/rules/research-mode.md
CHANGED
|
@@ -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
|
|
7
|
+
**Enforcement:** none.
|
|
8
8
|
|
|
9
|
-
**Full text:** [
|
|
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:** [
|
|
14
|
+
**Full text:** [guide](../docs/rule-guides/retired-hook-prose.md). Read it when tracing a hook's current path or removing its gate.
|
package/rules/session-title.md
CHANGED
|
@@ -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:** [
|
|
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
|
|
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:** [
|
|
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:** [
|
|
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
|
|
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
|
|
7
|
+
**Enforcement:** none.
|
|
8
8
|
|
|
9
|
-
**Full text:** [
|
|
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
|
|
7
|
+
**Enforcement:** none.
|
|
8
8
|
|
|
9
|
-
**Full text:** [
|
|
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:** [
|
|
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
|
|
13
|
+
**Enforcement:** none.
|
|
14
14
|
|
|
15
|
-
**Full text:** [
|
|
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:** [
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
)
|