diff --git a/src/context_system/prompt_assembly.py b/src/context_system/prompt_assembly.py index 41bb12ae..066cf6f8 100644 --- a/src/context_system/prompt_assembly.py +++ b/src/context_system/prompt_assembly.py @@ -1002,12 +1002,44 @@ def _emit_group( "understand and review your work. This is CRITICAL.\n" " - To read files use Read instead of cat, head, tail, or sed\n" " - To edit files use Edit instead of sed or awk\n" - " - To create files use Write instead of cat with heredoc or echo\n" + # The scope clause is the port's own rationale ("allows the user to + # better understand and review your work") applied consistently: it is + # about files the user reviews. Unscoped, the rule also caught scratch + # scripts, which cost a step every time — clawcodex authors a probe + # script with Write and then spends a second step running it, where the + # current Claude Code does both in one Bash call. Measured on seven + # shared terminal-bench 2.1 tasks at the same model and effort + # (2026-07-26): Claude Code authors 23.4% of its Bash calls via heredoc + # against clawcodex's 2.7%, and spends 13.6% of its tool calls on + # Write/Edit against clawcodex's 25.5%. The reference's own wording is a + # single soft line ("Prefer the dedicated file/search tools over shell + # commands when one fits"), so the absolute reading is not faithful either. + # Location is deliberately unspecified: it makes no permission difference. + # check_accept_edits_bash (src/permissions/bash_mode_validation.py:304) + # gates on _has_shell_redirection, not on where the redirect points -- + # `cat` is in ACCEPT_EDITS_READ_ONLY_COMMANDS and that branch requires + # `not redirection_anywhere` -- so every authoring form returns False and + # prompts, in-roots and /tmp alike (verified for `cat > /app/x < /tmp/x < ./x < /app/x`). + # + # KNOWN COST, accepted deliberately: in acceptEdits mode Write is + # auto-accepted while a Bash heredoc prompts, so taking this option trades + # a saved step for an approval prompt there. It is a clear win only where + # Bash is already unprompted (headless, bypass). Hence "may" rather than + # an instruction -- the model still has Write available and should use it + # when a prompt would cost more than the step saves. + " - To create files use Write instead of cat with heredoc or echo. A " + "throwaway script you run once and discard is part of the command " + "rather than a deliverable, and may instead be authored inline in the " + "same Bash call that runs it.\n" " - To search for files use Glob instead of find or ls\n" " - To search the content of files, use Grep instead of grep or rg\n" - " - Reserve using the Bash exclusively for system commands and terminal " - "operations that require shell execution. If you are unsure and there " - "is a relevant dedicated tool, default to using the dedicated tool.\n" + # "exclusively" was dropped: it cannot coexist with the throwaway-script + # carve-out above, and a prompt that states a rule and its exception in + # absolute terms leaves the model to guess which one governs. + " - Reserve Bash for system commands and terminal operations that " + "require shell execution. If you are unsure and there is a relevant " + "dedicated tool, default to using the dedicated tool.\n" "- Break down and manage your work with the TaskCreate tool.\n" # Two clauses were dropped in the port, and together they are what turns # a permission into a practice: the imperative to maximize, and the diff --git a/tests/test_system_prompt_full.py b/tests/test_system_prompt_full.py index 45af95b7..c41b1beb 100644 --- a/tests/test_system_prompt_full.py +++ b/tests/test_system_prompt_full.py @@ -161,6 +161,38 @@ def test_tools_prompt_pushes_parallel_tool_calls(self): "the dependency carve-out is what makes parallelizing safe to act on" ) + def test_throwaway_scripts_may_be_authored_inline(self): + """A one-shot script is part of the command, not a deliverable. + + The Write preference must survive for deliverables — that is what + keeps real edits reviewable as diffs — while a throwaway script the + agent runs once may be authored inside the Bash call that runs it, + saving a step. + + Stated as permission ("may"), not instruction, because the tradeoff + is context-dependent: in acceptEdits mode Write is auto-accepted + while any Bash redirect prompts (check_accept_edits_bash gates on + _has_shell_redirection, so the target path is irrelevant), so the + saved step costs an approval there. + + No location is specified — /tmp and in-roots paths prompt + identically, so mandating one buys nothing. + """ + prompt = build_full_system_prompt(use_cache=False) + assert "To create files use Write instead of cat with heredoc" in prompt, ( + "the deliverable path must still prefer Write, for reviewable diffs" + ) + assert "throwaway script you run once and discard" in prompt + assert "may instead be authored inline" in prompt, ( + "permission, not instruction — the tradeoff depends on whether " + "Bash is already unprompted in the caller's permission mode" + ) + tools_section = prompt.split("# Using your tools")[1].split("\n- Break")[0] + assert "/tmp" not in tools_section + assert "exclusively" not in tools_section, ( + "an absolute 'Reserve Bash exclusively' contradicts the carve-out" + ) + def test_identity_prompt_backward_compat(self): """_IDENTITY_PROMPT is an alias for _INTRO_SECTION.""" assert _IDENTITY_PROMPT is _INTRO_SECTION