From 8eab36d189c7b1582e0795147f67ebe9728c4667 Mon Sep 17 00:00:00 2001 From: asto Date: Mon, 2 Nov 2026 14:25:31 +0800 Subject: [PATCH 01/53] fix(tools): disclose read image and text decode The visible `read` tool described itself as text-only, but the contract reader returns sniffed PNG/JPEG/GIF/WebP files as inline image blocks and decodes everything else as UTF-8 with replacement characters instead of refusing invalid bytes (image OCR lives on the hidden legacy read path). Say so, and keep the 2000-line/50KB default window statement that matches the contract reader's effective budgets. Signed-off-by: asto --- crates/tui/src/tools/file_tool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/tools/file_tool.rs b/crates/tui/src/tools/file_tool.rs index b807912c67..c80725f5a1 100644 --- a/crates/tui/src/tools/file_tool.rs +++ b/crates/tui/src/tools/file_tool.rs @@ -60,7 +60,7 @@ impl ToolSpec for ReadTool { } fn description(&self) -> &'static str { - "Read a text file. By default, output is limited to 2000 complete lines or 50KB, whichever comes first. Use offset and limit to continue through large files." + "Read a file. PNG, JPEG, GIF, and WebP files are returned as images for visual inspection. Anything else is decoded as UTF-8 text; bytes that are not valid UTF-8 become replacement characters instead of failing the read. By default, text output is limited to 2000 complete lines or 50KB, whichever comes first. Use offset and limit to continue through large files." } fn input_schema(&self) -> Value { From 6f7a74d05a47b98164f12f0becadac7b10c23007 Mon Sep 17 00:00:00 2001 From: asto Date: Mon, 2 Nov 2026 14:25:36 +0800 Subject: [PATCH 02/53] fix(tools): disclose bash shell and stream facts The small-contract bash description claimed to "return stdout and stderr", but the contract path drains both descriptors through one shared pipe and concatenates them, so the model receives a single merged stream it cannot attribute per-fd. Each call also spawns a fresh shell: `cd` and exported variables do not persist between calls. State both facts up front. Signed-off-by: asto --- crates/tui/src/tools/shell.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 62aa201e55..86a115e85b 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -4775,7 +4775,7 @@ impl ToolSpec for LowercaseBashTool { } fn description(&self) -> &'static str { - "Execute a shell command in the workspace and return stdout and stderr. The command runs in the detected platform shell — `$SHELL` on Unix (any shell it names, falling back to `/bin/sh`); on Windows, bash when the session provides one, otherwise PowerShell (pwsh 7 or Windows PowerShell 5.1) or cmd.exe. Windows PowerShell 5.1 lacks `&&`; prefer forms the detected shell supports. In read-only shell sessions a single approved command runs directly as argv instead (only pipelines still go through the shell); with an external sandbox backend configured this tool is unavailable (use the backend-specific shell tool instead). Output keeps the last 2000 lines or 50KB. An optional timeout is expressed in seconds; when omitted the command is killed after 120 seconds, so pass an explicit timeout for work expected to take longer. In Ask, after a sandbox denial, retry the exact command once with sandbox_permissions (the narrowest wider mode that suffices) and a one-sentence justification; the approval prompt asks the user." + "Execute a shell command in the workspace and return its output. stdout and stderr arrive as one merged stream in arrival order, so a line cannot be attributed to one stream; redirect a command's stderr to a file when that attribution matters. Each call runs in a fresh shell — `cd` and exported variables do not persist between calls. The command runs in the detected platform shell — `$SHELL` on Unix (any shell it names, falling back to `/bin/sh`); on Windows, bash when the session provides one, otherwise PowerShell (pwsh 7 or Windows PowerShell 5.1) or cmd.exe. Windows PowerShell 5.1 lacks `&&`; prefer forms the detected shell supports. In read-only shell sessions a single approved command runs directly as argv instead (only pipelines still go through the shell); with an external sandbox backend configured this tool is unavailable (use the backend-specific shell tool instead). Output keeps the last 2000 lines or 50KB. An optional timeout is expressed in seconds; when omitted the command is killed after 120 seconds, so pass an explicit timeout for work expected to take longer. In Ask, after a sandbox denial, retry the exact command once with sandbox_permissions (the narrowest wider mode that suffices) and a one-sentence justification; the approval prompt asks the user." } fn input_schema(&self) -> serde_json::Value { From f248ce3152e66e5c4f495c8ac20bcb112a8a6972 Mon Sep 17 00:00:00 2001 From: asto Date: Mon, 2 Nov 2026 14:25:43 +0800 Subject: [PATCH 03/53] fix(skills): cite visible tool names in v4 skill v4-best-practices taught `read_file` and `edit_file`, both hidden compatibility names that never appear in a model-visible catalog or in a `tool_search` result; hosts that derive allowlists from the wire catalog reject the call outright. Cite the visible `read`/`edit` tools instead, extend the existing retired-name guard to this asset (it is not in BUNDLED_SKILLS, so the bundled-body sweep never covered it), and drop the stale scope note. Note: installs whose SKILL.md byte-matches the previously shipped body are no longer auto-retired by `retire_unchanged_v4_best_practices`; they keep the stale copy until user-deleted, same trade-off as the prior body edit. Signed-off-by: asto --- .../assets/skills/v4-best-practices/SKILL.md | 6 +++--- crates/tui/src/skills/system/tests.rs | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/tui/assets/skills/v4-best-practices/SKILL.md b/crates/tui/assets/skills/v4-best-practices/SKILL.md index f3d2ec59dc..b37d1b3fb5 100644 --- a/crates/tui/assets/skills/v4-best-practices/SKILL.md +++ b/crates/tui/assets/skills/v4-best-practices/SKILL.md @@ -11,15 +11,15 @@ specific, observable failure class. ## 1. Verify references before writing Before referencing a file path, function, or type in code or plan output, -call `grep_files` or `read_file` to confirm it exists in the workspace. +call `grep_files` or `read` to confirm it exists in the workspace. ``` -# Bad: edit_file path="src/config/loader.rs" (assumed from memory) +# Bad: edit path="src/config/loader.rs" (assumed from memory) # Good: grep_files pattern="pub fn load_config" → confirms src/config/mod.rs:42 # then reference src/config/mod.rs:42 ``` -Failure avoided: `edit_file` errors on non-existent paths; LSP diagnostics +Failure avoided: `edit` errors on non-existent paths; LSP diagnostics on hallucinated symbols. ## 2. Spawn a verifier sub-agent before multi-file execution diff --git a/crates/tui/src/skills/system/tests.rs b/crates/tui/src/skills/system/tests.rs index c3fab12458..3c4031fd44 100644 --- a/crates/tui/src/skills/system/tests.rs +++ b/crates/tui/src/skills/system/tests.rs @@ -62,6 +62,10 @@ fn bundled_integration_skills_use_current_codewhale_commands_and_paths() { ("help", HELP_BODY), ("delegate", DELEGATE_BODY), ("best-of-n", BEST_OF_N_BODY), + // Not in BUNDLED_SKILLS (legacy, retirement-only), but the shipped + // asset still reaches users who install or keep it, so the same + // visible-tool contract applies. + ("v4-best-practices", V4_BEST_PRACTICES_BODY), ] { assert!( !body.contains("read_file") && !body.contains("exec_shell"), @@ -81,9 +85,9 @@ fn bundled_integration_skills_use_current_codewhale_commands_and_paths() { // `update_plan`, `rlm`) plus the canonical retired-name lists // (`RETIRED_TOOL_NAMES`, the `agents/*` family); `list_dir` is deliberately // absent because it is a live, searchable tool (tools/file.rs ListDirTool), -// not a phantom. Scope note: `v4-best-practices` and `feishu` are not in -// BUNDLED_SKILLS (legacy/optional, never auto-installed), so this sweep does -// not cover them. +// not a phantom. Scope note: `v4-best-practices` is not in BUNDLED_SKILLS +// (legacy/optional, never auto-installed) but is swept explicitly below; +// `feishu` is opt-in only and not swept. const PHANTOM_NAMES: &[&str] = &[ "`File`", "`Bash`", @@ -133,6 +137,15 @@ fn forkguard_bundled_skills_cite_no_hidden_or_retired_tool_names() { ); } } + // Not in BUNDLED_SKILLS (legacy, retirement-only asset), but still + // shipped and installable, so it rides under the same contract. + for phantom in PHANTOM_NAMES { + assert!( + !V4_BEST_PRACTICES_BODY.contains(phantom), + "v4-best-practices cites {phantom}, which no model-visible \ + catalog or `tool_search` result can ever return" + ); + } } // The denylist above is a hand-maintained superset of the canonical lists in From 01851c726ba3c47fa9f73cc649fd5ed694e5a1dd Mon Sep 17 00:00:00 2001 From: asto Date: Mon, 2 Nov 2026 14:25:49 +0800 Subject: [PATCH 04/53] fix(tools): restore Run schema verifier semantics The visible `Run` wrapper reduced the hidden run_verifiers schema to bare one-line param docs, dropping the load-bearing clauses: custom gates run directly as program+args, not through a shell (reach for `bash -lc` only intentionally), and background gates return task_ids immediately with completion tracked in task/status state rather than pollable streams. Carry the hidden schema's wording into the visible surface, including per-field descriptions. Signed-off-by: asto --- crates/tui/src/tools/run_tool.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/tools/run_tool.rs b/crates/tui/src/tools/run_tool.rs index 406e8371fe..9fe8347184 100644 --- a/crates/tui/src/tools/run_tool.rs +++ b/crates/tui/src/tools/run_tool.rs @@ -106,21 +106,21 @@ impl ToolSpec for RunTool { }, "commands": { "type": "array", - "description": "Optional explicit verifier gates (action=verifiers)", + "description": "Optional explicit verifier gates (action=verifiers). Commands run directly as program+args, not through a shell; use program='bash', args=['-lc', '...'] only when Bash is intentionally part of the verifier.", "items": { "type": "object", "properties": { - "name": { "type": "string" }, - "program": { "type": "string" }, - "args": { "type": "array", "items": { "type": "string" } }, - "cwd": { "type": "string" } + "name": { "type": "string", "description": "Short unique gate name." }, + "program": { "type": "string", "description": "Executable to spawn, for example 'cargo', 'npm', 'pytest', or 'make'." }, + "args": { "type": "array", "items": { "type": "string" }, "description": "Arguments passed directly to the executable." }, + "cwd": { "type": "string", "description": "Optional working directory relative to the workspace." } }, "required": ["name", "program"] } }, "background": { "type": "boolean", - "description": "Start verifier gates as background jobs (action=verifiers)" + "description": "Start verifier gates as background jobs and return task_ids immediately (action=verifiers). Use for long build/test/lint gates; completion is tracked in task/status state, and Bash with action 'wait' or task_shell_wait is only for early output, final output, or true dependency barriers." } }, "required": ["action"] From be808c6ff4aa95b4a02022074e774c33bc53dd32 Mon Sep 17 00:00:00 2001 From: asto Date: Mon, 2 Nov 2026 14:25:56 +0800 Subject: [PATCH 05/53] fix(tools): refuse ambiguous fim_edit anchors fim_edit matched both anchors at their first occurrence with no uniqueness check, so a repeated anchor silently generated the middle at the wrong site. Count occurrences and refuse the edit when the prefix anchor repeats anywhere or the suffix anchor repeats after the prefix, naming the match count and asking for a longer anchor; disclose the exactly-once contract in the parameter schema. Covered by three new tests. Signed-off-by: asto --- crates/tui/src/tools/fim.rs | 99 +++++++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tools/fim.rs b/crates/tui/src/tools/fim.rs index 9e2f048c7f..0c15fc32c5 100644 --- a/crates/tui/src/tools/fim.rs +++ b/crates/tui/src/tools/fim.rs @@ -53,6 +53,14 @@ enum FimError { PrefixNotFound(String), #[error("Suffix anchor not found after prefix anchor: '{0}'")] SuffixNotFound(String), + #[error( + "Prefix anchor matches {1} locations in the file: '{0}'. Use a longer anchor that appears exactly once." + )] + PrefixAmbiguous(String, usize), + #[error( + "Suffix anchor matches {1} locations after the prefix anchor: '{0}'. Use a longer anchor that appears exactly once." + )] + SuffixAmbiguous(String, usize), #[error("Prefix and suffix anchors overlap (suffix starts at {0}, prefix ends at {1})")] AnchorsOverlap(usize, usize), #[error("FIM API call failed: {0}")] @@ -85,11 +93,11 @@ impl ToolSpec for FimEditTool { }, "prefix_anchor": { "type": "string", - "description": "Text anchor marking the end of the prefix. Everything up to and including this anchor is kept as-is before the generated middle." + "description": "Text anchor marking the end of the prefix. Everything up to and including this anchor is kept as-is before the generated middle. Must appear exactly once in the file." }, "suffix_anchor": { "type": "string", - "description": "Text anchor marking the start of the suffix. Everything from this anchor onward is kept as-is after the generated middle." + "description": "Text anchor marking the start of the suffix. Everything from this anchor onward is kept as-is after the generated middle. Must appear exactly once after the prefix anchor." }, "max_tokens": { "type": "integer", @@ -129,14 +137,30 @@ impl ToolSpec for FimEditTool { FimError::PrefixNotFound(prefix_anchor.to_string()).to_string(), ) })?; + // The anchors are matched at their first occurrence; an anchor that + // repeats would silently generate the middle at the wrong site, so + // ambiguity is refused instead of guessed. + let prefix_matches = content.matches(prefix_anchor).count(); + if prefix_matches > 1 { + return Err(ToolError::execution_failed( + FimError::PrefixAmbiguous(prefix_anchor.to_string(), prefix_matches).to_string(), + )); + } let prefix_end = prefix_pos + prefix_anchor.len(); // 3. Find suffix anchor (after prefix anchor) - let suffix_pos = content[prefix_end..].find(suffix_anchor).ok_or_else(|| { + let suffix_region = &content[prefix_end..]; + let suffix_pos = suffix_region.find(suffix_anchor).ok_or_else(|| { ToolError::execution_failed( FimError::SuffixNotFound(suffix_anchor.to_string()).to_string(), ) })?; + let suffix_matches = suffix_region.matches(suffix_anchor).count(); + if suffix_matches > 1 { + return Err(ToolError::execution_failed( + FimError::SuffixAmbiguous(suffix_anchor.to_string(), suffix_matches).to_string(), + )); + } let suffix_start = prefix_end + suffix_pos; // 4. Validate anchors don't overlap @@ -200,4 +224,73 @@ mod tests { assert!(!capabilities.contains(&ToolCapability::ReadOnly)); assert!(!tool.is_read_only()); } + + fn workspace_with(content: &str) -> (tempfile::TempDir, ToolContext) { + let tmp = tempfile::tempdir().expect("tempdir"); + let context = ToolContext::new(tmp.path().to_path_buf()); + std::fs::write(tmp.path().join("sample.txt"), content).expect("write fixture"); + (tmp, context) + } + + async fn refused(input: serde_json::Value, context: &ToolContext) -> String { + FimEditTool::new(None, "fim-model".to_string()) + .execute(input, context) + .await + .expect_err("ambiguous anchor must be refused") + .to_string() + } + + #[tokio::test] + async fn ambiguous_prefix_anchor_is_refused_not_first_occurrence_edited() { + let (_tmp, context) = workspace_with("fn a() {}\nfn main() {}\nfn a() {}\nlet _ = a;\n"); + let message = refused( + serde_json::json!({ + "path": "sample.txt", + "prefix_anchor": "fn a() {}", + "suffix_anchor": "let _ = a;", + }), + &context, + ) + .await; + assert!(message.contains("matches 2 locations"), "{message}"); + assert!(message.contains("exactly once"), "{message}"); + } + + #[tokio::test] + async fn ambiguous_suffix_anchor_is_refused() { + let (_tmp, context) = workspace_with("head\nreturn 1\nmid\nreturn 1\ntail\n"); + let message = refused( + serde_json::json!({ + "path": "sample.txt", + "prefix_anchor": "head", + "suffix_anchor": "return 1", + }), + &context, + ) + .await; + assert!( + message.contains("Suffix anchor matches 2 locations"), + "{message}" + ); + } + + #[tokio::test] + async fn unique_anchors_pass_validation_and_reach_the_fim_call() { + let (_tmp, context) = workspace_with("head\nmid\ntail\n"); + // With no client configured the call fails at the API boundary; that + // error proves the anchor validation accepted the unique anchors. + let message = refused( + serde_json::json!({ + "path": "sample.txt", + "prefix_anchor": "head", + "suffix_anchor": "tail", + }), + &context, + ) + .await; + assert!( + message.contains("FIM API client not available"), + "unique anchors must reach the API boundary: {message}" + ); + } } From e62cc2b3fca3d152f74f1f6ef25fb60ef770cbf0 Mon Sep 17 00:00:00 2001 From: asto Date: Mon, 2 Nov 2026 14:26:04 +0800 Subject: [PATCH 06/53] fix(tools): disclose read_media default budget The description and `detail` schema advertised the 5 MiB payload ceiling but not the 256 KiB delivery budget a default read ('auto'/'low') actually applies, while 'high'/'original' and crop reads keep the full budget. State both numbers where the model chooses a detail level. Signed-off-by: asto --- crates/tui/src/tools/read_media.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/read_media.rs b/crates/tui/src/tools/read_media.rs index 32ff68d9a2..a420201ff9 100644 --- a/crates/tui/src/tools/read_media.rs +++ b/crates/tui/src/tools/read_media.rs @@ -214,7 +214,7 @@ impl ToolSpec for ReadMediaTool { } fn description(&self) -> &'static str { - "Read an image file (PNG, JPEG, GIF, WebP) into context for multimodal/vision inspection, with optional crop region and detail level. Safe, bounded decode with pixel and byte guards." + "Read an image file (PNG, JPEG, GIF, WebP) into context for multimodal/vision inspection, with optional crop region and detail level. Default reads (detail 'auto' or 'low') deliver about 256KB; 'high'/'original' and crop reads keep the full 5 MiB payload limit. Safe, bounded decode with pixel and byte guards." } fn input_schema(&self) -> Value { @@ -251,7 +251,7 @@ impl ToolSpec for ReadMediaTool { "detail": { "type": "string", "enum": ["auto", "low", "high", "original"], - "description": "Resolution/detail preference. 'auto' (default) downscales large images to max 2048px; 'low' to max 1024px; 'high' / 'original' preserves resolution up to max 4096px within the 5 MiB payload limit." + "description": "Resolution/detail preference. 'auto' (default) downscales large images to max 2048px and delivers about 256KB; 'low' to max 1024px with the same 256KB delivery budget; 'high' / 'original' preserves resolution up to max 4096px within the full 5 MiB payload limit." } }, "required": ["path"] From cd7700f25b8b4b526111ab922b1d37f29bdc7d93 Mon Sep 17 00:00:00 2001 From: asto Date: Mon, 2 Nov 2026 14:26:05 +0800 Subject: [PATCH 07/53] fix(tools): disclose tasks schema semantics tasks create advertised `allow_shell`, `trust_mode`, and `auto_approve` with bare "(action=create)" placeholders. Document what each authority flag grants (shell permission with session-default inheritance, relaxed workspace-boundary path validation, auto-approved tool calls) and that a gate_run command the safety classifier rates dangerous is refused with BLOCKED before anything runs unless the session itself auto-approves. Signed-off-by: asto --- crates/tui/src/tools/tasks.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tools/tasks.rs b/crates/tui/src/tools/tasks.rs index c28a26c8a5..f082b96f3b 100644 --- a/crates/tui/src/tools/tasks.rs +++ b/crates/tui/src/tools/tasks.rs @@ -195,15 +195,15 @@ impl ToolSpec for TasksTool { ); properties.insert( "allow_shell".to_string(), - json!({ "type": "boolean", "description": "(action=create)" }), + json!({ "type": "boolean", "description": "Whether the task's session may run shell commands. Omit to inherit the current session's allow-shell setting. (action=create)" }), ); properties.insert( "trust_mode".to_string(), - json!({ "type": "boolean", "description": "(action=create)" }), + json!({ "type": "boolean", "description": "Run the task with trust mode: file paths are accepted without workspace-boundary validation. Defaults to false. (action=create)" }), ); properties.insert( "auto_approve".to_string(), - json!({ "type": "boolean", "description": "(action=create)" }), + json!({ "type": "boolean", "description": "Run the task with tool calls auto-approved (full-access posture, no approval prompts). Defaults to false. (action=create)" }), ); properties.insert( "gate".to_string(), @@ -215,7 +215,7 @@ impl ToolSpec for TasksTool { ); properties.insert( "command".to_string(), - json!({ "type": "string", "description": "Command to run. (action=gate_run)" }), + json!({ "type": "string", "description": "Command to run. A command the safety classifier rates dangerous is refused with BLOCKED (nothing runs) unless the session itself runs with auto-approve. (action=gate_run)" }), ); properties.insert( "cwd".to_string(), From 7db690cc5d95ce2d6592d56dfac25d33ecfb30de Mon Sep 17 00:00:00 2001 From: asto Date: Mon, 2 Nov 2026 14:26:13 +0800 Subject: [PATCH 08/53] fix(skills): drop dead short-description metadata The line-based frontmatter parser reads flat key/value pairs and no crate consumes `short-description`; the nested `metadata:` block only parsed as a no-op "metadata" key plus a stray top-level entry. Remove the four dead blocks; names, descriptions, and aliases-for are untouched. Signed-off-by: asto --- crates/tui/assets/skills/best-of-n/SKILL.md | 2 -- crates/tui/assets/skills/delegate/SKILL.md | 2 -- crates/tui/assets/skills/fleet-manager/SKILL.md | 2 -- crates/tui/assets/skills/skill-creator/SKILL.md | 2 -- 4 files changed, 8 deletions(-) diff --git a/crates/tui/assets/skills/best-of-n/SKILL.md b/crates/tui/assets/skills/best-of-n/SKILL.md index 62e2813919..6d3c78ecc7 100644 --- a/crates/tui/assets/skills/best-of-n/SKILL.md +++ b/crates/tui/assets/skills/best-of-n/SKILL.md @@ -1,8 +1,6 @@ --- name: best-of-n description: Generate a small set of independent candidate solutions in worktrees, judge them against one explicit rubric, and apply the winner only after PASS verification. -metadata: - short-description: Compare independent candidates --- # Best of N diff --git a/crates/tui/assets/skills/delegate/SKILL.md b/crates/tui/assets/skills/delegate/SKILL.md index 9576baa869..783e287747 100644 --- a/crates/tui/assets/skills/delegate/SKILL.md +++ b/crates/tui/assets/skills/delegate/SKILL.md @@ -1,8 +1,6 @@ --- name: delegate description: Strategic delegation for multi-step coding, research, or verification work. Use when a task can be split into parent reasoning plus focused sub-agent execution through the agent tool. -metadata: - short-description: Delegate focused work to sub-agents --- # Delegate diff --git a/crates/tui/assets/skills/fleet-manager/SKILL.md b/crates/tui/assets/skills/fleet-manager/SKILL.md index 42c4616927..8faf4c7c35 100644 --- a/crates/tui/assets/skills/fleet-manager/SKILL.md +++ b/crates/tui/assets/skills/fleet-manager/SKILL.md @@ -1,8 +1,6 @@ --- name: fleet-manager description: Use when managing, triaging, restarting, escalating, or summarizing Codewhale fleet runs and workers. -metadata: - short-description: Triage Codewhale fleet runs --- # Fleet Manager diff --git a/crates/tui/assets/skills/skill-creator/SKILL.md b/crates/tui/assets/skills/skill-creator/SKILL.md index 8e4434a7d3..f7ff245299 100644 --- a/crates/tui/assets/skills/skill-creator/SKILL.md +++ b/crates/tui/assets/skills/skill-creator/SKILL.md @@ -1,8 +1,6 @@ --- name: skill-creator description: Create or improve Codewhale skills. Use when the user wants a new skill, wants to update an existing skill, or needs guidance on when a skill should be a skill versus MCP, hooks, tools, or a plugin scaffold. -metadata: - short-description: Create Codewhale skills aliases-for: create-skill --- From bca4382c9180b27cc035a1d679433aba4c2a4f22 Mon Sep 17 00:00:00 2001 From: asto Date: Mon, 2 Nov 2026 14:26:13 +0800 Subject: [PATCH 09/53] docs(prompts): fix stale overlay doc comment The module header listed the personality overlay as assembly step 2, but the runtime folds personality tone into the constitutional preamble and never composes CALM_PERSONALITY (tests pin the Calm overlay out of the default prompt). Describe the real assembly order and mark the constant as retired test-only text. Signed-off-by: asto --- crates/tui/src/prompts/text.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/prompts/text.rs b/crates/tui/src/prompts/text.rs index b4a0fa8376..bcc5e618fd 100644 --- a/crates/tui/src/prompts/text.rs +++ b/crates/tui/src/prompts/text.rs @@ -13,12 +13,17 @@ //! most-volatile (see `system_prompt_for_mode_with_context_skills_and_session` //! in `../prompts.rs`): //! -//! 1. Constitution (binding core: `BASE_PROMPT` + language/output law) -//! 2. Personality overlay (`CALM_PERSONALITY` — one overlay, not a set) -//! 3. Approval-policy overlays -//! 4. Runtime templates (compaction relay, goal continuation, memory, +//! 1. Constitution (binding core: `BASE_PROMPT` + language/output law; +//! personality tone is folded into the constitutional preamble, not +//! composed as a separate layer) +//! 2. Approval-policy overlays +//! 3. Runtime templates (compaction relay, goal continuation, memory, //! core execution, sub-agent output contract) //! +//! `CALM_PERSONALITY` below is a retired overlay: the runtime never composes +//! it (tests assert the Calm tone stays folded into the constitution), and it +//! is kept only so the historic text and its guard tests remain inspectable. +//! //! Edit prompt text here directly. Content and ordering invariants are //! guarded by the test suite in `../prompts.rs` (constitution structure, //! binding gates, prefix privacy, byte-stable prefix ordering) — run From bfa732d91fb646250fc81906d0004cdee4ec9e5f Mon Sep 17 00:00:00 2001 From: asto Date: Mon, 2 Nov 2026 14:26:30 +0800 Subject: [PATCH 10/53] fix(tools): disclose lsp line requirement definition and references fail with "requires line (1-based)" when the line parameter is missing, but the schema described it as a plain "1-based line." with no requirement. Name the operations that need it and pin the string in the schema test. Signed-off-by: asto18089 --- crates/tui/src/tools/lsp.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/lsp.rs b/crates/tui/src/tools/lsp.rs index fa77e75eb7..8449a8b7ca 100644 --- a/crates/tui/src/tools/lsp.rs +++ b/crates/tui/src/tools/lsp.rs @@ -45,7 +45,7 @@ impl ToolSpec for LspTool { "line": { "type": "integer", "minimum": 1, - "description": "1-based line." + "description": "1-based line. Required for definition and references." }, "character": { "type": "integer", @@ -495,7 +495,10 @@ mod tests { .unwrap() .contains("1-16 newline-separated") ); - assert_eq!(properties["line"]["description"], "1-based line."); + assert_eq!( + properties["line"]["description"], + "1-based line. Required for definition and references." + ); assert_eq!(properties["character"]["default"], 1); assert_eq!( properties["character"]["description"], From ca2af5ff6f94d8fee9b49b36e21b8af71673ccf5 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 16:48:23 +0800 Subject: [PATCH 11/53] fix(tools): disclose remaining task schema semantics Nine task-tool properties still carried bare "(action=...)" descriptions: model, mode, timeout_ms, the attempt bookkeeping fields, verification, attempt_id, and limit. Describe each from its actual execution path (defaults, clamping, failure shapes). Also correct the fresh allow_shell copy: omitting the flag resolves through the durable-task profile snapshot (config allow_shell(), default false), not the interactive session's effective setting. Signed-off-by: asto18089 --- crates/tui/src/tools/tasks.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/tools/tasks.rs b/crates/tui/src/tools/tasks.rs index f082b96f3b..0ce48bb578 100644 --- a/crates/tui/src/tools/tasks.rs +++ b/crates/tui/src/tools/tasks.rs @@ -183,7 +183,7 @@ impl ToolSpec for TasksTool { ); properties.insert( "model".to_string(), - json!({ "type": "string", "description": "(action=create)" }), + json!({ "type": "string", "description": "Model id for the task's session, used as given. Omit to use the configured default model. Required when model_provider or model_provider_id is set. (action=create)" }), ); properties.insert( "workspace".to_string(), @@ -191,11 +191,11 @@ impl ToolSpec for TasksTool { ); properties.insert( "mode".to_string(), - json!({ "type": "string", "enum": ["agent", "plan", "operate"], "description": "(action=create)" }), + json!({ "type": "string", "enum": ["agent", "plan", "operate"], "description": "Operating mode for the task's session. 'plan' runs read-only: shell is disabled even if allow_shell is true, and approvals stay enabled. 'agent' (default) and 'operate' run with the task's allow_shell/trust_mode/auto_approve values. (action=create)" }), ); properties.insert( "allow_shell".to_string(), - json!({ "type": "boolean", "description": "Whether the task's session may run shell commands. Omit to inherit the current session's allow-shell setting. (action=create)" }), + json!({ "type": "boolean", "description": "Whether the task's session may run shell commands. Passing true or false is always honored; omitting falls back to the configured durable-task allow-shell setting, which is false unless shell execution is enabled in config. (action=create)" }), ); properties.insert( "trust_mode".to_string(), @@ -223,19 +223,19 @@ impl ToolSpec for TasksTool { ); properties.insert( "timeout_ms".to_string(), - json!({ "type": "integer", "minimum": 1000, "maximum": 600000, "description": "(action=gate_run)" }), + json!({ "type": "integer", "minimum": 1000, "maximum": 600000, "description": "Kill deadline for the gate command in milliseconds. Defaults to 120000; values are clamped into 1000-600000. On expiry the process group is killed, the result reports timed_out with no exit code, and any partial output is kept. (action=gate_run)" }), ); properties.insert( "attempt_group_id".to_string(), - json!({ "type": "string", "description": "(action=pr_attempt_record)" }), + json!({ "type": "string", "description": "Label grouping related attempts for correlation across pr_attempt_list and pr_attempt_read; shown in the task timeline. Any string; a fresh random group id is generated when omitted. (action=pr_attempt_record)" }), ); properties.insert( "attempt_index".to_string(), - json!({ "type": "integer", "minimum": 1, "description": "(action=pr_attempt_record)" }), + json!({ "type": "integer", "minimum": 1, "description": "This attempt's position within its group (1-based), shown as 'index/count' in the task timeline. Defaults to 1; values below 1 are clamped to 1. (action=pr_attempt_record)" }), ); properties.insert( "attempt_count".to_string(), - json!({ "type": "integer", "minimum": 1, "description": "(action=pr_attempt_record)" }), + json!({ "type": "integer", "minimum": 1, "description": "Total number of attempts intended for the group, shown as 'index/count' in the task timeline. Defaults to 1; values below 1 are clamped to 1. (action=pr_attempt_record)" }), ); properties.insert( "summary".to_string(), @@ -243,12 +243,12 @@ impl ToolSpec for TasksTool { ); properties.insert( "verification".to_string(), - json!({ "type": "array", "items": { "type": "string" }, "description": "(action=pr_attempt_record)" }), + json!({ "type": "array", "items": { "type": "string" }, "description": "Verification evidence lines recorded with the attempt, returned by pr_attempt_list and pr_attempt_read. Omit for an empty list; non-string entries are dropped. (action=pr_attempt_record)" }), ); } properties.insert( "attempt_id".to_string(), - json!({ "type": "string", "description": "(action=pr_attempt_read/preflight)" }), + json!({ "type": "string", "description": "Attempt id to fetch, as returned by pr_attempt_list. Matched exactly; an unknown id fails with 'Attempt not found'. (action=pr_attempt_read/preflight)" }), ); properties.insert( "task_id".to_string(), @@ -256,7 +256,7 @@ impl ToolSpec for TasksTool { ); properties.insert( "limit".to_string(), - json!({ "type": "integer", "minimum": 1, "maximum": 100, "default": 20, "description": "(action=list)" }), + json!({ "type": "integer", "minimum": 1, "maximum": 100, "default": 20, "description": "Maximum number of tasks returned, newest first. Defaults to 20; values are clamped into 1-100. (action=list)" }), ); json!({ "type": "object", From 4447e58b881e35935c5b3c5cc17b223bc8f855b1 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 16:49:08 +0800 Subject: [PATCH 12/53] fix(skills): retire legacy v4-best-practices bodies retire_unchanged_v4_best_practices compared installed copies against the current asset only, so the PR edit that fixed read_file/edit_file in the shipped body stopped retiring exactly the installs that need it: those still teach retired tool names. Pin the previously shipped body as a legacy const and retire byte-exact matches of either body; user edits stay. The retirement test wrote the same const the retire fn compares against, so its assertion was a tautology and no longer simulated any real install. Write the legacy bytes instead and pin the two constants apart; an appended-line case proves only byte-exact bodies retire. Signed-off-by: asto18089 --- crates/tui/src/skills/system.rs | 67 +++++++++++++++++++++++++-- crates/tui/src/skills/system/tests.rs | 31 +++++++++++-- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/crates/tui/src/skills/system.rs b/crates/tui/src/skills/system.rs index faf2fa02cd..7771b8adbc 100644 --- a/crates/tui/src/skills/system.rs +++ b/crates/tui/src/skills/system.rs @@ -136,9 +136,66 @@ const CONTRIBUTOR_ONBOARDING_BODY: &str = const FEISHU_BODY: &str = include_str!("../../assets/skills/feishu/SKILL.md"); const MCP_DISCOVERY_BODY: &str = include_str!("../../assets/skills/mcp-discovery/SKILL.md"); -// Legacy v4 body retained solely for digest-based safe retirement (#4691). +// v4-best-practices ships as a plain asset (not in BUNDLED_SKILLS); it is +// kept for byte-exact safe retirement (#4691), never auto-installed. const V4_BEST_PRACTICES_BODY: &str = include_str!("../../assets/skills/v4-best-practices/SKILL.md"); +/// The v4-best-practices body shipped before the tool-name fix; it teaches +/// the retired `read_file`/`edit_file` names. Pinned byte-for-byte so +/// [`retire_unchanged_v4_best_practices`] keeps retiring unmodified legacy +/// installs after the shipped asset moves on; user edits stay untouched. +const V4_BEST_PRACTICES_LEGACY_BODY: &str = r#"--- +name: v4-best-practices +description: Use when working with deepseek-v4-pro or deepseek-v4-flash in thinking mode on multi-step or plan-driven tasks. Provides rules to prevent stale references, unverified plan assumptions, and vague plan output. +--- + +# V4 Best Practices + +Rules for multi-step V4 thinking-mode workflows. Each rule prevents a +specific, observable failure class. + +## 1. Verify references before writing + +Before referencing a file path, function, or type in code or plan output, +call `grep_files` or `read_file` to confirm it exists in the workspace. + +``` +# Bad: edit_file path="src/config/loader.rs" (assumed from memory) +# Good: grep_files pattern="pub fn load_config" → confirms src/config/mod.rs:42 +# then reference src/config/mod.rs:42 +``` + +Failure avoided: `edit_file` errors on non-existent paths; LSP diagnostics +on hallucinated symbols. + +## 2. Spawn a verifier sub-agent before multi-file execution + +Before executing a plan that touches 3+ files, spawn a `deepseek-v4-flash` +sub-agent (thinking off) to read the target files and confirm path/symbol +assumptions still hold. + +``` +agent type="verifier" model="deepseek-v4-flash" + prompt: "Read these files and confirm: [list assumptions]. Report mismatches." +``` + +Failure avoided: multi-step edits fail partway because file structure +changed since the plan was drafted. + +## 3. Plan output must use confirmed path:line references + +In plan-mode output, replace vague location pointers with `path:line` +references drawn from a prior `grep_files` result. + +``` +# Bad: "Update the retry logic in the client module" +# Good: "Update retry loop at crates/tui/src/client.rs:187" +``` + +Failure avoided: agent-mode execution cannot locate the intended edit +target when plan directions are imprecise. +"#; + struct BundledSkill { name: &'static str, body: &'static str, @@ -385,7 +442,7 @@ pub fn bundled_skill_generation() -> &'static str { BUNDLED_SKILL_VERSION } -/// Legacy v4-best-practices body digest helper (not in BUNDLED_SKILLS). +/// Current v4-best-practices body (not in BUNDLED_SKILLS). fn v4_best_practices_body() -> &'static str { V4_BEST_PRACTICES_BODY } @@ -523,8 +580,8 @@ pub fn install_system_skills(skills_dir: &Path) -> std::io::Result<()> { } /// Delete `v4-best-practices` only when the installed SKILL.md exactly matches -/// the last shipped bundled body (byte-for-byte). Modified or user-owned copies -/// are preserved. +/// the current or the previously shipped asset body (byte-for-byte). Modified +/// or user-owned copies are preserved. fn retire_unchanged_v4_best_practices(skills_dir: &Path) -> std::io::Result { let dir = skills_dir.join("v4-best-practices"); let file = dir.join("SKILL.md"); @@ -532,7 +589,7 @@ fn retire_unchanged_v4_best_practices(skills_dir: &Path) -> std::io::Result Date: Mon, 2 Nov 2026 16:50:10 +0800 Subject: [PATCH 13/53] fix(skills): refresh superseded bundled bodies from the short-description cleanup The four bundled SKILL.md bodies stripped of dead short-description frontmatter changed shipped bytes without the non-destructive upgrade half: no superseded digest entries and no generation bump. Per the #4691 convention (bump plus entries together, as 10->11 and 11->12 did), bump to generation 13 so installed gen-12 copies refresh at next launch, and record the four previous-body digests so those copies count as unmodified shipped text instead of user edits. Signed-off-by: asto18089 --- crates/tui/assets/skills-catalog-matrix.json | 2 +- crates/tui/src/skills/system.rs | 20 +++++++++++++++++++- crates/tui/src/skills/system/tests.rs | 9 +++++---- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/tui/assets/skills-catalog-matrix.json b/crates/tui/assets/skills-catalog-matrix.json index 3525589db9..f3f3f11928 100644 --- a/crates/tui/assets/skills-catalog-matrix.json +++ b/crates/tui/assets/skills-catalog-matrix.json @@ -15,7 +15,7 @@ "in_model_catalogue": "true when the skill renders as an ambient catalogue line", "shadowed_aliases": "aliases that collide with another canonical bundled name; the canonical skill wins resolution" }, - "generation": "12", + "generation": "13", "skills": [ { "name": "skill-creator", diff --git a/crates/tui/src/skills/system.rs b/crates/tui/src/skills/system.rs index 7771b8adbc..cd2d0f8d8d 100644 --- a/crates/tui/src/skills/system.rs +++ b/crates/tui/src/skills/system.rs @@ -20,7 +20,9 @@ use std::path::Path; /// route, xlsx recalc caveat) and refreshes superseded shipped copies via /// [`SUPERSEDED_SHIPPED_BODY_DIGESTS`]. /// Generation 12 respects host-provided built-in discovery entry points. -const BUNDLED_SKILL_VERSION: &str = "12"; +/// Generation 13 refreshes bundled bodies stripped of dead short-description +/// frontmatter. +const BUNDLED_SKILL_VERSION: &str = "13"; /// SHA-256 digests of bundled bodies shipped by the previous generation that /// this generation replaces. The non-destructive upgrade table (#4691) must @@ -32,6 +34,14 @@ const BUNDLED_SKILL_VERSION: &str = "12"; /// only when installs older than the dropped generation are no longer /// supported. const SUPERSEDED_SHIPPED_BODY_DIGESTS: &[(&str, &str)] = &[ + ( + "best-of-n", + "b72d093fcbcf4e35185a2a6880cad24af03f764e279ba34ae47f7b449688213f", + ), + ( + "delegate", + "169640cc236cf9eaae852843eb0f06c4f093f20e8fceb6b7a780ebf8f805d302", + ), ( "documents", "27ba3161ab9448245d6658efadccf955ee8f4ef2cda4f7b825a99c45b8bd66aa", @@ -40,6 +50,10 @@ const SUPERSEDED_SHIPPED_BODY_DIGESTS: &[(&str, &str)] = &[ "docx", "fb2edb80df2119e42c1febcb5eae74c15092ba1e5dfbedf91ee45118ed9c7556", ), + ( + "fleet-manager", + "fa4369c7192553e986bb68644c7163a60e39fc6e530d4746d43830f2d369930a", + ), ( "mcp-builder", "3caa3ca14b9fc9560726642c4b73c62b5261da580e882e144d36842904f02bb6", @@ -64,6 +78,10 @@ const SUPERSEDED_SHIPPED_BODY_DIGESTS: &[(&str, &str)] = &[ "presentations", "ecddd5663a93bc1afca20ef2a5bdac26258a98557aa1a743797c0a5fa720a247", ), + ( + "skill-creator", + "6fb9e9212ad53b9ecc0e7bb8b6ee40e047a94b2b93f0bd6705d234cc082474d9", + ), ( "skill-installer", "cd4ac950534bceba6265e326cbc09998c296db97e094fe81698c22030bfc8bba", diff --git a/crates/tui/src/skills/system/tests.rs b/crates/tui/src/skills/system/tests.rs index b37cd0ba4f..b6a46ae55e 100644 --- a/crates/tui/src/skills/system/tests.rs +++ b/crates/tui/src/skills/system/tests.rs @@ -218,8 +218,9 @@ fn contributor_onboarding_ships_at_generation_8_and_keeps_its_refusals() { // The pin tracks the current catalog generation: 9 added handoff, // 10 added mcp-discovery (#5238), 11 corrected bundled skill bodies // from the model-facing text audit (#112) and refreshes superseded - // shipped copies; 12 updates host-aware discovery. - assert_eq!(BUNDLED_SKILL_VERSION, "12"); + // shipped copies; 12 updates host-aware discovery; 13 refreshes bundled + // bodies stripped of dead short-description frontmatter. + assert_eq!(BUNDLED_SKILL_VERSION, "13"); let body = skill.body; assert!(body.contains("invocation: explicit-only")); @@ -680,7 +681,7 @@ fn version_bump_refreshes_superseded_shipped_body() { current, "superseded shipped body must be refreshed on bump" ); - assert_eq!(fs::read_to_string(marker_file(&tmp)).unwrap().trim(), "12"); + assert_eq!(fs::read_to_string(marker_file(&tmp)).unwrap().trim(), "13"); } #[test] @@ -787,7 +788,7 @@ fn forkguard_skill_upgrade_refreshes_shipped_mcp_discovery_and_preserves_user_ed let expected_version = if customized && !peer_present { "11" } else { - "12" + "13" }; if peer_present { assert_eq!( From 4499d39838b8716aa89fb81f99f92d677e9a7a12 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 16:50:55 +0800 Subject: [PATCH 14/53] fix(prompts): finish stale overlay header cleanup The previous header fix corrected the personality line but kept renumbering "Approval-policy overlays" as a composed layer; those overlays were removed as dead code and approval policy travels as request-time runtime metadata. Drop the layer and say where approval policy actually lives; align the prompts.rs module header, which still named a personality overlay step. Signed-off-by: asto18089 --- crates/tui/src/prompts.rs | 2 +- crates/tui/src/prompts/text.rs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index c65da075fe..6aad3dd52c 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -3,7 +3,7 @@ //! //! Prompts are assembled from composable layers loaded at compile time from //! the single `text` module: -//! constitution + personality overlay → `message[0]` (byte-stable). +//! constitution (personality folded in) → `message[0]` (byte-stable). //! approval policy → request-time runtime metadata. //! Tool availability comes only from the per-turn model catalog. //! diff --git a/crates/tui/src/prompts/text.rs b/crates/tui/src/prompts/text.rs index bcc5e618fd..76475a179c 100644 --- a/crates/tui/src/prompts/text.rs +++ b/crates/tui/src/prompts/text.rs @@ -16,10 +16,12 @@ //! 1. Constitution (binding core: `BASE_PROMPT` + language/output law; //! personality tone is folded into the constitutional preamble, not //! composed as a separate layer) -//! 2. Approval-policy overlays -//! 3. Runtime templates (compaction relay, goal continuation, memory, +//! 2. Runtime templates (compaction relay, goal continuation, memory, //! core execution, sub-agent output contract) //! +//! Approval policy is not a layer here: it travels as request-time runtime +//! metadata (see the `prompts.rs` module header). +//! //! `CALM_PERSONALITY` below is a retired overlay: the runtime never composes //! it (tests assert the Calm tone stays folded into the constitution), and it //! is kept only so the historic text and its guard tests remain inspectable. From 1a190462f7f057f7b7adec115dd16ee29cf8949d Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 16:51:18 +0800 Subject: [PATCH 15/53] fix(tools): disclose read image size cap and BMP behavior The read description claimed images unconditionally, but the inline path caps at 5 MiB (larger images come back as an omission note) and BMP is sniffed into the image branch yet rejected by the validity check, so its bytes never reach the model as text or image. Name both cutoffs; the sibling read_media description already discloses the same 5 MiB limit. Signed-off-by: asto18089 --- crates/tui/src/tools/file_tool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/tools/file_tool.rs b/crates/tui/src/tools/file_tool.rs index c80725f5a1..5b418cdc77 100644 --- a/crates/tui/src/tools/file_tool.rs +++ b/crates/tui/src/tools/file_tool.rs @@ -60,7 +60,7 @@ impl ToolSpec for ReadTool { } fn description(&self) -> &'static str { - "Read a file. PNG, JPEG, GIF, and WebP files are returned as images for visual inspection. Anything else is decoded as UTF-8 text; bytes that are not valid UTF-8 become replacement characters instead of failing the read. By default, text output is limited to 2000 complete lines or 50KB, whichever comes first. Use offset and limit to continue through large files." + "Read a file. PNG, JPEG, GIF, and WebP files up to 5 MiB are returned as images for visual inspection; BMP files and images above 5 MiB are detected but replaced with an omission note. Anything else is decoded as UTF-8 text; bytes that are not valid UTF-8 become replacement characters instead of failing the read. By default, text output is limited to 2000 complete lines or 50KB, whichever comes first. Use offset and limit to continue through large files." } fn input_schema(&self) -> Value { From 26ea3162307c666807efcf7eaddc59d414205b30 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 16:51:31 +0800 Subject: [PATCH 16/53] fix(tools): stop citing retired exec_shell in cwd recovery hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate_shell_working_dir told the model to pass an explicit working_dir/cwd "to exec_shell" — a name retired in v0.9.3 that no catalog or tool_search result can return. The hint also offered working_dir, an alias only the hidden compatibility tool accepts. Ask for an explicit cwd, which the surfaces that reach this path can set. Signed-off-by: asto18089 --- crates/tui/src/tools/shell.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 86a115e85b..7789a3a52a 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -130,7 +130,7 @@ fn validate_shell_working_dir(path: &Path, inherited_session_workspace: bool) -> "requested working directory" }; format!( - "{source} is unavailable: {}. Restore or remap that directory, resume/fork the session from an existing workspace, or pass an explicit `working_dir`/`cwd` to exec_shell", + "{source} is unavailable: {}. Restore or remap that directory, resume/fork the session from an existing workspace, or pass an explicit `cwd`", path.display() ) })?; @@ -141,7 +141,7 @@ fn validate_shell_working_dir(path: &Path, inherited_session_workspace: bool) -> "requested working directory" }; return Err(anyhow!( - "{source} is not a directory: {}. Resume/fork from an existing workspace or pass an explicit `working_dir`/`cwd`", + "{source} is not a directory: {}. Resume/fork from an existing workspace or pass an explicit `cwd`", path.display() )); } From e454f70eb37474a35b3d7ff8065eb94c5fab4684 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 16:52:58 +0800 Subject: [PATCH 17/53] docs: document read_media delivery budget The schema now advertises the 256 KiB auto/low delivery budget, but docs/READ_MEDIA.md still listed only the 5 MiB wire ceiling. Record the budget and the crop/high/original exception beside the other guards. Signed-off-by: asto18089 --- docs/READ_MEDIA.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/READ_MEDIA.md b/docs/READ_MEDIA.md index 573f47188a..73362f7bbd 100644 --- a/docs/READ_MEDIA.md +++ b/docs/READ_MEDIA.md @@ -74,6 +74,7 @@ always_load = ["read_media"] - **Pixel Budget:** Maximum total pixel count is **33,554,432 pixels** (~33.5 megapixels, `MAX_IMAGE_PIXELS`). - **Memory Ceiling:** Safe memory allocation during decode is capped at **64 MiB** (`MAX_DECODE_ALLOC_BYTES`). - **Wire Payload Limit:** Re-encoded image payload is capped at **5 MiB** (`MAX_WIRE_IMAGE_BYTES`), matching provider constraints. +- **Delivery Budget:** Default full reads (`detail` `"auto"`/`"low"`) are re-encoded through a quality/size ladder to fit a **256 KiB** delivery budget (`READ_IMAGE_BYTE_BUDGET`, roughly 350 KB on the wire after base64). `"high"` / `"original"` detail and `crop` reads keep the full **5 MiB** wire budget. ### 4.3. Active Route Vision Checks - Before reading an image, `read_media` inspects `context.route_capabilities.image_input`. From 969624f8766ef322752deca5f631e7357579f781 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 17:06:57 +0800 Subject: [PATCH 18/53] fix(tools): name published tools in the Run verifier schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 4 carried the hidden run_verifiers wording verbatim into the visible Run schema, including "Bash with action 'wait'" — a hidden compatibility name the catalog cannot return, and an action the visible bash tool does not even take. The pinned guard no_advertised_tool_teaches_a_retired_name rejects exactly this, so the schema text regressed a repo invariant. Point the wait guidance at task_shell_wait (the published waiter the background metadata itself names in poll_with) and drop the compat-name mention; the hidden schema keeps its own wording for its own consumers. Signed-off-by: asto18089 --- crates/tui/src/tools/run_tool.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/run_tool.rs b/crates/tui/src/tools/run_tool.rs index 9fe8347184..1d9424135b 100644 --- a/crates/tui/src/tools/run_tool.rs +++ b/crates/tui/src/tools/run_tool.rs @@ -106,7 +106,7 @@ impl ToolSpec for RunTool { }, "commands": { "type": "array", - "description": "Optional explicit verifier gates (action=verifiers). Commands run directly as program+args, not through a shell; use program='bash', args=['-lc', '...'] only when Bash is intentionally part of the verifier.", + "description": "Optional explicit verifier gates (action=verifiers). Commands run directly as program+args, not through a shell; use program='bash', args=['-lc', '...'] only when the shell is intentionally part of the verifier.", "items": { "type": "object", "properties": { @@ -120,7 +120,7 @@ impl ToolSpec for RunTool { }, "background": { "type": "boolean", - "description": "Start verifier gates as background jobs and return task_ids immediately (action=verifiers). Use for long build/test/lint gates; completion is tracked in task/status state, and Bash with action 'wait' or task_shell_wait is only for early output, final output, or true dependency barriers." + "description": "Start verifier gates as background jobs and return task_ids immediately (action=verifiers). Use for long build/test/lint gates; completion is tracked in task/status state, and task_shell_wait is only for early output, final output, or true dependency barriers." } }, "required": ["action"] From 5f86b1af6ccb966585afb87e0702edbd89d8f7d5 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 18:51:27 +0800 Subject: [PATCH 19/53] fix(tools): scope the Run verifier copy to each execution path The commands description claimed verifier gates "run directly as program+args, not through a shell" unconditionally, but only foreground gates spawn directly; background gates render the command into a single line and hand it to the detected platform shell (render_gate_command -> ShellManager), where POSIX quoting can fail under PowerShell. Scope the sentence per path and disclose the background routing. The background description also claimed a literal "task_ids" field, which exists only in tool-result metadata the model never sees; the model-visible payload carries per-gate task ids - reworded. The commands items object now carries additionalProperties:false, matching CustomVerifierInput's deny_unknown_fields so the advertised schema matches the actual hard fail on unknown item keys. Signed-off-by: asto18089 --- crates/tui/src/tools/run_tool.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tools/run_tool.rs b/crates/tui/src/tools/run_tool.rs index 1d9424135b..b3d475ac42 100644 --- a/crates/tui/src/tools/run_tool.rs +++ b/crates/tui/src/tools/run_tool.rs @@ -106,7 +106,7 @@ impl ToolSpec for RunTool { }, "commands": { "type": "array", - "description": "Optional explicit verifier gates (action=verifiers). Commands run directly as program+args, not through a shell; use program='bash', args=['-lc', '...'] only when the shell is intentionally part of the verifier.", + "description": "Optional explicit verifier gates (action=verifiers). Foreground gates run directly as program+args, not through a shell; use program='bash', args=['-lc', '...'] only when the shell is intentionally part of the verifier. Background gates are rendered into a single command line and run through the detected platform shell, so shell quoting rules apply there (a POSIX-quoted path can fail under PowerShell).", "items": { "type": "object", "properties": { @@ -115,12 +115,13 @@ impl ToolSpec for RunTool { "args": { "type": "array", "items": { "type": "string" }, "description": "Arguments passed directly to the executable." }, "cwd": { "type": "string", "description": "Optional working directory relative to the workspace." } }, - "required": ["name", "program"] + "required": ["name", "program"], + "additionalProperties": false } }, "background": { "type": "boolean", - "description": "Start verifier gates as background jobs and return task_ids immediately (action=verifiers). Use for long build/test/lint gates; completion is tracked in task/status state, and task_shell_wait is only for early output, final output, or true dependency barriers." + "description": "Start verifier gates as background jobs and return immediately with each gate's task id (action=verifiers). Use for long build/test/lint gates; completion is tracked in task/status state, and task_shell_wait is only for early output, final output, or true dependency barriers." } }, "required": ["action"] From 6521c3418b7be55e31aed8729e33a4dd34932a20 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 18:51:27 +0800 Subject: [PATCH 20/53] fix(tools): make the bash working-dir recovery hint actionable The reworded hints told the model to "pass an explicit `cwd`", but the visible bash schema accepts only command/timeout/sandbox_permissions/ justification and contract_bash_legacy_input rejects everything else, so the suggestion failed with "unexpected bash parameter(s)" on the very surface that surfaced the error (and the call is refused before spawn, so `cd` inside the command cannot help either). Surfaces that do accept cwd advertise it in their own schema, so the hint now names only the recovery options that work everywhere: restore/remap the directory or resume/fork from an existing workspace. Signed-off-by: asto18089 --- crates/tui/src/tools/shell.rs | 4 ++-- crates/tui/src/tools/shell/tests.rs | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 7789a3a52a..ed4faac6cf 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -130,7 +130,7 @@ fn validate_shell_working_dir(path: &Path, inherited_session_workspace: bool) -> "requested working directory" }; format!( - "{source} is unavailable: {}. Restore or remap that directory, resume/fork the session from an existing workspace, or pass an explicit `cwd`", + "{source} is unavailable: {}. Restore or remap that directory, or resume/fork the session from an existing workspace", path.display() ) })?; @@ -141,7 +141,7 @@ fn validate_shell_working_dir(path: &Path, inherited_session_workspace: bool) -> "requested working directory" }; return Err(anyhow!( - "{source} is not a directory: {}. Resume/fork from an existing workspace or pass an explicit `cwd`", + "{source} is not a directory: {}. Resume/fork the session from an existing workspace", path.display() )); } diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index d73535648b..7164fc6ba1 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -581,7 +581,6 @@ fn deleted_saved_workspace_reports_path_and_recovery_before_spawn() { let message = error.to_string(); assert!(message.contains("saved session workspace is unavailable")); assert!(message.contains(&stale.display().to_string())); - assert!(message.contains("working_dir") || message.contains("cwd")); assert!(message.contains("resume/fork")); } From 6935e8e12df3204001339d2b35aa61b670678c17 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 18:51:27 +0800 Subject: [PATCH 21/53] fix(tools): correct task mode approval copy and dedupe declared ranges The mode description claimed "'plan' ... and approvals stay enabled", but RuntimePolicyProjection::from_request maps auto_approve=true to ApprovalMode::Bypass regardless of mode, so plan+auto_approve runs with approvals bypassed while plan's hard blocks (shell, writes, sandbox) still hold - reworded to state that. Range restatements duplicating the schema-declared minimum/maximum/ default in the same property (timeout_ms range, limit default+range, attempt_index/attempt_count range) are folded to a single "out-of-range values are clamped, not rejected" clause; defaults that the schema does not carry ("Defaults to 120000/1") are kept. Signed-off-by: asto18089 --- crates/tui/src/tools/tasks.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/tools/tasks.rs b/crates/tui/src/tools/tasks.rs index 0ce48bb578..aca0a9d8e5 100644 --- a/crates/tui/src/tools/tasks.rs +++ b/crates/tui/src/tools/tasks.rs @@ -191,7 +191,7 @@ impl ToolSpec for TasksTool { ); properties.insert( "mode".to_string(), - json!({ "type": "string", "enum": ["agent", "plan", "operate"], "description": "Operating mode for the task's session. 'plan' runs read-only: shell is disabled even if allow_shell is true, and approvals stay enabled. 'agent' (default) and 'operate' run with the task's allow_shell/trust_mode/auto_approve values. (action=create)" }), + json!({ "type": "string", "enum": ["agent", "plan", "operate"], "description": "Operating mode for the task's session. 'plan' runs read-only: shell is disabled even if allow_shell is true, and plan's read-only blocks are not lifted by auto_approve (which otherwise skips approval prompts). 'agent' (default) and 'operate' run with the task's allow_shell/trust_mode/auto_approve values. (action=create)" }), ); properties.insert( "allow_shell".to_string(), @@ -223,7 +223,7 @@ impl ToolSpec for TasksTool { ); properties.insert( "timeout_ms".to_string(), - json!({ "type": "integer", "minimum": 1000, "maximum": 600000, "description": "Kill deadline for the gate command in milliseconds. Defaults to 120000; values are clamped into 1000-600000. On expiry the process group is killed, the result reports timed_out with no exit code, and any partial output is kept. (action=gate_run)" }), + json!({ "type": "integer", "minimum": 1000, "maximum": 600000, "description": "Kill deadline for the gate command in milliseconds. Defaults to 120000; out-of-range values are clamped, not rejected. On expiry the process group is killed, the result reports timed_out with no exit code, and any partial output is kept. (action=gate_run)" }), ); properties.insert( "attempt_group_id".to_string(), @@ -231,11 +231,11 @@ impl ToolSpec for TasksTool { ); properties.insert( "attempt_index".to_string(), - json!({ "type": "integer", "minimum": 1, "description": "This attempt's position within its group (1-based), shown as 'index/count' in the task timeline. Defaults to 1; values below 1 are clamped to 1. (action=pr_attempt_record)" }), + json!({ "type": "integer", "minimum": 1, "description": "This attempt's position within its group (1-based), shown as 'index/count' in the task timeline. Defaults to 1; out-of-range values are clamped, not rejected. (action=pr_attempt_record)" }), ); properties.insert( "attempt_count".to_string(), - json!({ "type": "integer", "minimum": 1, "description": "Total number of attempts intended for the group, shown as 'index/count' in the task timeline. Defaults to 1; values below 1 are clamped to 1. (action=pr_attempt_record)" }), + json!({ "type": "integer", "minimum": 1, "description": "Total number of attempts intended for the group, shown as 'index/count' in the task timeline. Defaults to 1; out-of-range values are clamped, not rejected. (action=pr_attempt_record)" }), ); properties.insert( "summary".to_string(), @@ -256,7 +256,7 @@ impl ToolSpec for TasksTool { ); properties.insert( "limit".to_string(), - json!({ "type": "integer", "minimum": 1, "maximum": 100, "default": 20, "description": "Maximum number of tasks returned, newest first. Defaults to 20; values are clamped into 1-100. (action=list)" }), + json!({ "type": "integer", "minimum": 1, "maximum": 100, "default": 20, "description": "Maximum number of tasks returned, newest first. Out-of-range values are clamped, not rejected. (action=list)" }), ); json!({ "type": "object", From 6ee7116dd5f32cdb3604b73b33bb662d2641f499 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 18:51:39 +0800 Subject: [PATCH 22/53] fix(tools): disclose fim anchor uniqueness in the tool description The schema properties disclose the exactly-once anchor contract but the tool-level description did not, unlike the sibling edit tool which discloses uniqueness at both levels - added the clause with the match count on refusal. Also fixes the in-code comment tense (the check suppresses the first-occurrence matching it described in present tense) and renames the test helper refused -> execution_error, since the unique-anchors test reuses it where nothing is refused. Signed-off-by: asto18089 --- crates/tui/src/tools/fim.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/tools/fim.rs b/crates/tui/src/tools/fim.rs index 0c15fc32c5..294e0e3757 100644 --- a/crates/tui/src/tools/fim.rs +++ b/crates/tui/src/tools/fim.rs @@ -80,7 +80,9 @@ impl ToolSpec for FimEditTool { calls the active route's fill-in-the-middle completion endpoint to \ generate replacement content; this requires the active provider to \ expose a fill-in-the-middle completions endpoint, and the call fails \ - otherwise." + otherwise. Each anchor must appear exactly once — the prefix anywhere \ + in the file, the suffix after the prefix — and ambiguous anchors are \ + refused with their match count." } fn input_schema(&self) -> Value { @@ -137,9 +139,9 @@ impl ToolSpec for FimEditTool { FimError::PrefixNotFound(prefix_anchor.to_string()).to_string(), ) })?; - // The anchors are matched at their first occurrence; an anchor that - // repeats would silently generate the middle at the wrong site, so - // ambiguity is refused instead of guessed. + // Without this check an anchor that repeats would be matched only at + // its first occurrence and silently generate the middle at the wrong + // site, so ambiguity is refused instead of guessed. let prefix_matches = content.matches(prefix_anchor).count(); if prefix_matches > 1 { return Err(ToolError::execution_failed( @@ -232,18 +234,20 @@ mod tests { (tmp, context) } - async fn refused(input: serde_json::Value, context: &ToolContext) -> String { + /// Run `execute` and return the error text; every caller asserts on the + /// specific error it expects. + async fn execution_error(input: serde_json::Value, context: &ToolContext) -> String { FimEditTool::new(None, "fim-model".to_string()) .execute(input, context) .await - .expect_err("ambiguous anchor must be refused") + .expect_err("expected the fim_edit call to fail") .to_string() } #[tokio::test] async fn ambiguous_prefix_anchor_is_refused_not_first_occurrence_edited() { let (_tmp, context) = workspace_with("fn a() {}\nfn main() {}\nfn a() {}\nlet _ = a;\n"); - let message = refused( + let message = execution_error( serde_json::json!({ "path": "sample.txt", "prefix_anchor": "fn a() {}", @@ -259,7 +263,7 @@ mod tests { #[tokio::test] async fn ambiguous_suffix_anchor_is_refused() { let (_tmp, context) = workspace_with("head\nreturn 1\nmid\nreturn 1\ntail\n"); - let message = refused( + let message = execution_error( serde_json::json!({ "path": "sample.txt", "prefix_anchor": "head", @@ -279,7 +283,7 @@ mod tests { let (_tmp, context) = workspace_with("head\nmid\ntail\n"); // With no client configured the call fails at the API boundary; that // error proves the anchor validation accepted the unique anchors. - let message = refused( + let message = execution_error( serde_json::json!({ "path": "sample.txt", "prefix_anchor": "head", From 5c012817f9002ad9af8f399995cb29f0f2668fab Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 18:51:39 +0800 Subject: [PATCH 23/53] docs(prompts): finish the stale assembly-layer cleanup BASE_PROMPT's doc still said the runtime composes "mode / approval / skills / context-management / compaction / authority-recap layers", contradicting the corrected module headers from 29a1ecb76; it now describes the folded constitution head, WorldState fragments and per-request trailers, and approval policy as request-time runtime metadata. The volatile-content-last doc on system_prompt_for_mode_with_context_and_skills still listed the compaction relay template and relay block as system blocks the builder no longer appends; it now lists the real sequence (composed constitution prefix, WorldState fragments, authority-recap and locale-closer trailers). The remaining bare "overlay" wordings (CALM_PERSONALITY section header and doc, StaticPromptCtx fields, the embedder hook doc) are aligned with the retired framing. Signed-off-by: asto18089 --- crates/tui/src/prompts.rs | 42 +++++++++++++++++++--------------- crates/tui/src/prompts/text.rs | 17 +++++++++----- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 6aad3dd52c..6d24f0d444 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -447,15 +447,16 @@ static PROMPT_OVERRIDE_NOTICES: LazyLock>> = pub struct StaticPromptCtx<'a> { /// Active model identifier after caller-side routing. pub model_id: &'a str, - /// Personality overlay requested for the base static prompt. + /// Personality requested for the base static prompt (retired: the runtime + /// folds the tone into the constitution rather than composing an overlay). pub personality: Personality, - /// Default base/personality prompt layers that would be used without an - /// override. + /// Default base prompt (personality already folded in) that would be used + /// without an override. pub default_layers: &'a str, } -/// Embedder hook for replacing Codewhale's byte-stable base/personality prompt -/// segment. +/// Embedder hook for replacing Codewhale's byte-stable base prompt segment +/// (personality folded in). pub type StaticPromptComposer = dyn Fn(&StaticPromptCtx<'_>) -> String + Send + Sync + 'static; /// Install an embedder composer that owns the complete static prompt prefix. @@ -1094,21 +1095,26 @@ pub fn system_prompt_for_mode_with_context( /// Get the system prompt for a specific mode with project and skills context. /// -/// **Volatile-content-last invariant.** Blocks are appended in order from -/// most-static to most-volatile so DeepSeek's KV prefix cache hits the -/// longest possible byte prefix turn-over-turn: +/// **Volatile-content-last invariant.** The cache-stable `Blocks[0]` prefix +/// is the composed constitution — base prompt, project context, skills +/// block, and `## Core Execution` discipline — and per-turn material follows +/// it from most-static to most-volatile so DeepSeek's KV prefix cache hits +/// the longest possible byte prefix turn-over-turn: /// -/// 1. shared constitution (compile-time constant) -/// 2. project context / fallback (workspace-static) -/// 3. skills block (skills-dir-static) -/// 4. `## Core Execution` (compile-time constant) -/// 5. compaction relay template (compile-time constant) -/// 6. relay block — file-backed; rewritten by `/compact` and on exit +/// 1. composed constitution prefix (compile-time constant, plus +/// workspace-static project context and the skills-dir-static skills +/// block) +/// 2. WorldState fragments (environment/memory/goal, permissions, route, +/// handoff) that can `render_diff` mid-session +/// 3. per-request trailers: authority recap, then locale closer /// -/// Anything appended after a volatile block forfeits the cache for the rest -/// of the request. New blocks belong above the relay boundary unless they -/// themselves are turn-volatile. Working-set metadata is now injected into the -/// latest user message as per-turn metadata instead of this system prompt. +/// The compaction relay is not a system block: automatic compaction owns its +/// structured successor brief and `/relay` appends `COMPACT_TEMPLATE` to that +/// command's user message. Anything appended after a volatile block forfeits +/// the cache for the rest of the request; new content belongs above the +/// fragment boundary unless it is itself turn-volatile. Working-set metadata +/// is injected into the latest user message as per-turn metadata instead of +/// this system prompt. pub fn system_prompt_for_mode_with_context_and_skills( workspace: &Path, working_set_summary: Option<&str>, diff --git a/crates/tui/src/prompts/text.rs b/crates/tui/src/prompts/text.rs index 76475a179c..101d999801 100644 --- a/crates/tui/src/prompts/text.rs +++ b/crates/tui/src/prompts/text.rs @@ -42,10 +42,14 @@ /// system prompt. The earlier YAML + Python-renderer generation pipeline /// (`constitution.yaml` / `render_constitution.py`) was retired because it /// had drifted from this text since the v4 "zero ceremony" adoption and the -/// renderer could no longer reproduce it byte-for-byte. The layered runtime -/// assembly composes this core with mode / approval / skills / -/// context-management / compaction / authority-recap layers at runtime (see -/// `system_prompt_for_mode_with_context_skills_and_session`). Edit the text +/// renderer could no longer reproduce it byte-for-byte. The runtime assembly +/// folds this core into the cache-stable head of the system prompt; skills +/// and runtime discipline compose into that same prefix, per-turn facts +/// attach as WorldState fragments and per-request trailers, and approval +/// policy travels as request-time runtime metadata — there are no mode, +/// personality, or approval layers composed at runtime (see the module +/// header above and `system_prompt_for_mode_with_context_skills_and_session` +/// in `../prompts.rs`). Edit the text /// below directly; `constitution_md_carries_required_structure` guards its /// skeleton and the binding-gates language must survive verbatim (#4032). pub const BASE_PROMPT: &str = r#"## Codewhale @@ -161,8 +165,9 @@ Prefer plain prose for explanations; bulleted or numbered lists for sequential o If you genuinely need column-aligned data because the user asked for a table or for `/cost`-style output, keep columns narrow, ASCII-only, and limited to two or three columns. Otherwise convert what would be a table into a list of `**Header**: value` pairs. "#; -// ── Personality overlays — voice and tone ────────────────────────── -/// Calm personality overlay. +// ── Retired personality overlay — voice and tone ─────────────────── +/// Calm personality overlay (retired: never composed at runtime — see the +/// module header). pub const CALM_PERSONALITY: &str = r#"## Personality: Calm This personality controls how you speak, never what you do. It cannot override From 52d999609806d610292972ff56b10b602198177d Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 18:51:40 +0800 Subject: [PATCH 24/53] fix(skills): pin the v4 legacy body by digest with a test fixture The legacy v4-best-practices pin duplicated the full 1.7KiB historic body as a source constant while the compare target and the test fixture were that same constant, so transcription drift on either side would keep every test green while real legacy installs silently stop retiring. Replaces the byte pin with a SHA-256 digest (the same mechanism as SUPERSEDED_SHIPPED_BODY_DIGESTS) and moves the historic bytes into a test fixture that asserts on every run that they still hash to the pinned digest, so drift on either side fails the suite. Also restores dedicated coverage for the current-body retire arm (lost when the retirement test was de-tautologized) with upgrade_retires_byte_exact_current_v4_body_installs, and aligns two comments with actual behavior: byte-exact keeps are retired at the next launch, and feishu has no install path at all rather than being "opt-in". Signed-off-by: asto18089 --- crates/tui/src/skills/system.rs | 83 ++++++------------- .../fixtures/v4-best-practices-legacy.md | 50 +++++++++++ crates/tui/src/skills/system/tests.rs | 53 ++++++++++-- 3 files changed, 120 insertions(+), 66 deletions(-) create mode 100644 crates/tui/src/skills/system/fixtures/v4-best-practices-legacy.md diff --git a/crates/tui/src/skills/system.rs b/crates/tui/src/skills/system.rs index cd2d0f8d8d..79853cb8f6 100644 --- a/crates/tui/src/skills/system.rs +++ b/crates/tui/src/skills/system.rs @@ -158,61 +158,26 @@ const MCP_DISCOVERY_BODY: &str = include_str!("../../assets/skills/mcp-discovery // kept for byte-exact safe retirement (#4691), never auto-installed. const V4_BEST_PRACTICES_BODY: &str = include_str!("../../assets/skills/v4-best-practices/SKILL.md"); -/// The v4-best-practices body shipped before the tool-name fix; it teaches -/// the retired `read_file`/`edit_file` names. Pinned byte-for-byte so -/// [`retire_unchanged_v4_best_practices`] keeps retiring unmodified legacy -/// installs after the shipped asset moves on; user edits stay untouched. -const V4_BEST_PRACTICES_LEGACY_BODY: &str = r#"--- -name: v4-best-practices -description: Use when working with deepseek-v4-pro or deepseek-v4-flash in thinking mode on multi-step or plan-driven tasks. Provides rules to prevent stale references, unverified plan assumptions, and vague plan output. ---- - -# V4 Best Practices - -Rules for multi-step V4 thinking-mode workflows. Each rule prevents a -specific, observable failure class. - -## 1. Verify references before writing - -Before referencing a file path, function, or type in code or plan output, -call `grep_files` or `read_file` to confirm it exists in the workspace. - -``` -# Bad: edit_file path="src/config/loader.rs" (assumed from memory) -# Good: grep_files pattern="pub fn load_config" → confirms src/config/mod.rs:42 -# then reference src/config/mod.rs:42 -``` - -Failure avoided: `edit_file` errors on non-existent paths; LSP diagnostics -on hallucinated symbols. - -## 2. Spawn a verifier sub-agent before multi-file execution - -Before executing a plan that touches 3+ files, spawn a `deepseek-v4-flash` -sub-agent (thinking off) to read the target files and confirm path/symbol -assumptions still hold. - -``` -agent type="verifier" model="deepseek-v4-flash" - prompt: "Read these files and confirm: [list assumptions]. Report mismatches." -``` - -Failure avoided: multi-step edits fail partway because file structure -changed since the plan was drafted. - -## 3. Plan output must use confirmed path:line references - -In plan-mode output, replace vague location pointers with `path:line` -references drawn from a prior `grep_files` result. - -``` -# Bad: "Update the retry logic in the client module" -# Good: "Update retry loop at crates/tui/src/client.rs:187" -``` - -Failure avoided: agent-mode execution cannot locate the intended edit -target when plan directions are imprecise. -"#; +/// SHA-256 of the v4-best-practices body shipped before the tool-name fix; +/// that body taught the retired `read_file`/`edit_file` names. Pinning the +/// digest (not the bytes) lets [`retire_unchanged_v4_best_practices`] keep +/// retiring unmodified legacy installs after the shipped asset moves on, +/// without duplicating the historic text into the binary. The historical +/// bytes live in the `system/tests.rs` fixture, which asserts on every run +/// that they still hash to this digest, so drift on either side fails the +/// suite. +const V4_BEST_PRACTICES_LEGACY_BODY_SHA256: &str = + "cca76c3e988219304eb23cf86e07f9972d2f74480f37d1bcc78245927cd3f2a3"; + +/// True when `body` hashes to the previously shipped v4-best-practices body +/// (see [`V4_BEST_PRACTICES_LEGACY_BODY_SHA256`]). +fn is_legacy_v4_best_practices_body(body: &str) -> bool { + let digest: String = Sha256::digest(body.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + digest == V4_BEST_PRACTICES_LEGACY_BODY_SHA256 +} struct BundledSkill { name: &'static str, @@ -597,9 +562,9 @@ pub fn install_system_skills(skills_dir: &Path) -> std::io::Result<()> { Ok(()) } -/// Delete `v4-best-practices` only when the installed SKILL.md exactly matches -/// the current or the previously shipped asset body (byte-for-byte). Modified -/// or user-owned copies are preserved. +/// Delete `v4-best-practices` only when the installed SKILL.md is +/// byte-identical to the current asset or hash-identical to the previously +/// shipped body. Modified or user-owned copies are preserved. fn retire_unchanged_v4_best_practices(skills_dir: &Path) -> std::io::Result { let dir = skills_dir.join("v4-best-practices"); let file = dir.join("SKILL.md"); @@ -607,7 +572,7 @@ fn retire_unchanged_v4_best_practices(skills_dir: &Path) -> std::io::Result std::path::PathBuf { tmp.path().join(".system-installed-version") } +/// The v4-best-practices body shipped before the tool-name fix. Kept as a +/// fixture (instead of a source constant) so the pinned digest in +/// `system.rs` is checked against the real historic bytes on every run. +const V4_LEGACY_BODY_FIXTURE: &str = include_str!("fixtures/v4-best-practices-legacy.md"); + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + // ── fresh install ───────────────────────────────────────────────────────── #[test] @@ -63,8 +76,8 @@ fn bundled_integration_skills_use_current_codewhale_commands_and_paths() { ("delegate", DELEGATE_BODY), ("best-of-n", BEST_OF_N_BODY), // Not in BUNDLED_SKILLS (legacy, retirement-only), but the shipped - // asset still reaches users who install or keep it, so the same - // visible-tool contract applies. + // asset still reaches installs until a launch retires their + // byte-exact copy, so the same visible-tool contract applies. ("v4-best-practices", V4_BEST_PRACTICES_BODY), ] { assert!( @@ -87,7 +100,7 @@ fn bundled_integration_skills_use_current_codewhale_commands_and_paths() { // absent because it is a live, searchable tool (tools/file.rs ListDirTool), // not a phantom. Scope note: `v4-best-practices` is not in BUNDLED_SKILLS // (legacy/optional, never auto-installed) but is swept explicitly below; -// `feishu` is opt-in only and not swept. +// `feishu` is never installed and not swept. const PHANTOM_NAMES: &[&str] = &[ "`File`", "`Bash`", @@ -559,15 +572,21 @@ fn upgrade_from_v4_installs_pack_and_retires_unchanged_v4_best_practices() { let tmp = TempDir::new().unwrap(); // Simulate a v4 install: marker + legacy skill bodies. The installed copy // carries the previously shipped body (still teaching read_file/edit_file), - // not the current asset. + // not the current asset. The digest self-check fails the suite if either + // the fixture or the pinned digest drifts. + assert_eq!( + sha256_hex(V4_LEGACY_BODY_FIXTURE.as_bytes()), + V4_BEST_PRACTICES_LEGACY_BODY_SHA256, + "legacy fixture must keep matching the pinned digest" + ); assert_ne!( - V4_BEST_PRACTICES_LEGACY_BODY, V4_BEST_PRACTICES_BODY, + V4_LEGACY_BODY_FIXTURE, V4_BEST_PRACTICES_BODY, "legacy pin must track the previously shipped body" ); fs::create_dir_all(skill_dir(&tmp, "v4-best-practices")).unwrap(); fs::write( skill_file(&tmp, "v4-best-practices"), - V4_BEST_PRACTICES_LEGACY_BODY, + V4_LEGACY_BODY_FIXTURE, ) .unwrap(); fs::write(marker_file(&tmp), "4").unwrap(); @@ -596,7 +615,7 @@ fn upgrade_preserves_legacy_v4_body_plus_user_addendum() { fs::create_dir_all(skill_dir(&tmp, "v4-best-practices")).unwrap(); fs::write( skill_file(&tmp, "v4-best-practices"), - format!("{V4_BEST_PRACTICES_LEGACY_BODY}\nAppended by the user\n"), + format!("{V4_LEGACY_BODY_FIXTURE}\nAppended by the user\n"), ) .unwrap(); fs::write(marker_file(&tmp), "4").unwrap(); @@ -609,6 +628,26 @@ fn upgrade_preserves_legacy_v4_body_plus_user_addendum() { ); } +#[test] +fn upgrade_retires_byte_exact_current_v4_body_installs() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(skill_dir(&tmp, "v4-best-practices")).unwrap(); + fs::write( + skill_file(&tmp, "v4-best-practices"), + V4_BEST_PRACTICES_BODY, + ) + .unwrap(); + fs::write(marker_file(&tmp), "4").unwrap(); + + install_system_skills(tmp.path()).unwrap(); + + assert!( + !skill_dir(&tmp, "v4-best-practices").exists(), + "the shipped v4 asset is retired alongside the legacy body; only \ + user edits are preserved" + ); +} + #[test] fn upgrade_preserves_modified_v4_best_practices() { let tmp = TempDir::new().unwrap(); From ba047292dea4ff8e8783968e93107bf0b95b76ff Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 18:29:10 +0800 Subject: [PATCH 25/53] fix(tools): make the shell working-dir recovery advice hold on every surface resume re-anchors to the same saved (missing) workspace on every surface (TUI restore and headless both run the saved pair) and a CLI fork copies the saved workspace, so the old resume/fork advice could loop forever and 'remap' named no engine facility. Advice now splits on the same source flag as the label: recreate-or-switch for the saved session workspace (naming /workspace and /fork, which anchor where they say), pass-or-use an existing directory for a requested working directory, whose surfaces advertise cwd in their own schema. Signed-off-by: asto --- crates/tui/src/tools/shell.rs | 34 +++++++++++++++++------------ crates/tui/src/tools/shell/tests.rs | 28 +++++++++++++++++++++++- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index ed4faac6cf..2a788ed087 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -124,24 +124,30 @@ pub(crate) fn abort_pending_persistent_process_groups_for_exit() { fn validate_shell_working_dir(path: &Path, inherited_session_workspace: bool) -> Result<()> { let metadata = std::fs::metadata(path).with_context(|| { - let source = if inherited_session_workspace { - "saved session workspace" + if inherited_session_workspace { + // Resume re-anchors to this same saved directory and a CLI fork + // copies it, so neither recovers a missing workspace; only + // recreating the directory or switching (in-app) does. + format!( + "saved session workspace is unavailable: {}. Recreate the directory to continue here, or continue the session from a different workspace (/workspace , or /fork after switching)", + path.display() + ) } else { - "requested working directory" - }; - format!( - "{source} is unavailable: {}. Restore or remap that directory, or resume/fork the session from an existing workspace", - path.display() - ) + format!( + "requested working directory is unavailable: {}. Recreate it, or use a directory that exists", + path.display() + ) + } })?; if !metadata.is_dir() { - let source = if inherited_session_workspace { - "saved session workspace" - } else { - "requested working directory" - }; + if inherited_session_workspace { + return Err(anyhow!( + "saved session workspace is not a directory: {}. Restore it as a directory to continue here, or continue the session from a different workspace (/workspace , or /fork after switching)", + path.display() + )); + } return Err(anyhow!( - "{source} is not a directory: {}. Resume/fork the session from an existing workspace", + "requested working directory is not a directory: {}. Use a directory that exists", path.display() )); } diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 7164fc6ba1..5be3a01e37 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -581,7 +581,33 @@ fn deleted_saved_workspace_reports_path_and_recovery_before_spawn() { let message = error.to_string(); assert!(message.contains("saved session workspace is unavailable")); assert!(message.contains(&stale.display().to_string())); - assert!(message.contains("resume/fork")); + assert!(message.contains("Recreate the directory")); + assert!(message.contains("/workspace")); + assert!( + !message.contains("resume/fork"), + "resume re-anchors to the saved (missing) workspace and cannot recover" + ); +} + +#[test] +fn explicit_non_directory_working_dir_gets_a_direct_fix_hint() { + let workspace = tempdir().expect("workspace"); + let file = workspace.path().join("not-a-dir"); + std::fs::write(&file, b"occupant").expect("write occupant file"); + let mut manager = ShellManager::new(workspace.path().to_path_buf()); + + let error = execute_shell( + &mut manager, + "echo should-not-run", + file.to_str(), + 1_000, + false, + ) + .expect_err("non-directory explicit cwd must fail before shell spawn"); + let message = error.to_string(); + assert!(message.contains("requested working directory is not a directory")); + assert!(message.contains("Use a directory that exists")); + assert!(!message.contains("saved session workspace")); } #[test] From 6c69378603d14b1dbca5d7cd86d8d4ade0bf1190 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 18:29:20 +0800 Subject: [PATCH 26/53] fix(tools): disclose the trust_mode full-access retry in the tasks schema trust_mode has a second effect the copy omitted: under auto_approve, a tool call the sandbox denies is retried with full access instead of being denied (approval_decision maps trust_mode to RetryWithFullAccess). The schema now states both effects; the round-2 scope cut this one line. Signed-off-by: asto --- crates/tui/src/tools/tasks.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/tools/tasks.rs b/crates/tui/src/tools/tasks.rs index aca0a9d8e5..9494d18b11 100644 --- a/crates/tui/src/tools/tasks.rs +++ b/crates/tui/src/tools/tasks.rs @@ -199,7 +199,7 @@ impl ToolSpec for TasksTool { ); properties.insert( "trust_mode".to_string(), - json!({ "type": "boolean", "description": "Run the task with trust mode: file paths are accepted without workspace-boundary validation. Defaults to false. (action=create)" }), + json!({ "type": "boolean", "description": "Run the task with trust mode: file paths are accepted without workspace-boundary validation, and when auto_approve is on, a tool call the sandbox denies is retried with full access instead of being denied. Defaults to false. (action=create)" }), ); properties.insert( "auto_approve".to_string(), From fc416b7c4a3137be87381b9efc1ba4c4e2147666 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 18:29:20 +0800 Subject: [PATCH 27/53] fix(tools): qualify background gate task ids for skipped or failed gates The background payload carries task_id per gate, but skipped and failed-to-start gates deliver task_id: null with their own status and reason fields; say so instead of implying every gate gets an id. Signed-off-by: asto --- crates/tui/src/tools/run_tool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/tools/run_tool.rs b/crates/tui/src/tools/run_tool.rs index b3d475ac42..c4f33013fd 100644 --- a/crates/tui/src/tools/run_tool.rs +++ b/crates/tui/src/tools/run_tool.rs @@ -121,7 +121,7 @@ impl ToolSpec for RunTool { }, "background": { "type": "boolean", - "description": "Start verifier gates as background jobs and return immediately with each gate's task id (action=verifiers). Use for long build/test/lint gates; completion is tracked in task/status state, and task_shell_wait is only for early output, final output, or true dependency barriers." + "description": "Start verifier gates as background jobs and return immediately with a task id per gate, null when a gate is skipped or fails to start (action=verifiers). Use for long build/test/lint gates; completion is tracked in task/status state, and task_shell_wait is only for early output, final output, or true dependency barriers." } }, "required": ["action"] From 36636d20c9fb57f35c847299485b297ee213f189 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 18:29:20 +0800 Subject: [PATCH 28/53] fix(tools): scope the hidden run_verifiers shell claim per execution path The hidden compatibility schema kept the unscoped 'not through a shell' sentence that the visible Run schema already corrected: background gates are rendered into one command line and run through the detected platform shell. Mirror the scoped wording; the hidden background description is unchanged (task_ids and the Bash action-wait wording are accurate for its metadata-level consumers). Signed-off-by: asto --- crates/tui/src/tools/verifier.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/tools/verifier.rs b/crates/tui/src/tools/verifier.rs index aba4d275b2..4f86376bd3 100644 --- a/crates/tui/src/tools/verifier.rs +++ b/crates/tui/src/tools/verifier.rs @@ -275,7 +275,7 @@ impl ToolSpec for RunVerifiersTool { }, "commands": { "type": "array", - "description": "Optional explicit verifier gates. Commands run directly as program+args, not through a shell. Use program='bash', args=['-lc', '...'] only when Bash is intentionally part of the verifier.", + "description": "Optional explicit verifier gates. Foreground gates run directly as program+args, not through a shell; background gates are rendered into a single command line and run through the detected platform shell, so shell quoting rules apply there. Use program='bash', args=['-lc', '...'] only when Bash is intentionally part of the verifier.", "items": { "type": "object", "properties": { From 1eb944fb14c520ed4c12709fd56ba789e541b6ec Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 18:29:37 +0800 Subject: [PATCH 29/53] test(tools): pin crop reads keeping the full wire budget The crop carve-out (crop_applied picks MAX_WIRE_IMAGE_BYTES over the detail budget) had no direct coverage; a crop+auto read of a source over the ~256KB budget now asserts delivery past the detail budget while staying within the 5 MiB wire budget. Signed-off-by: asto --- crates/tui/src/tools/read_media.rs | 47 ++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/tui/src/tools/read_media.rs b/crates/tui/src/tools/read_media.rs index a420201ff9..bfaf585c3c 100644 --- a/crates/tui/src/tools/read_media.rs +++ b/crates/tui/src/tools/read_media.rs @@ -1724,6 +1724,53 @@ mod tests { (img.width(), img.height(), bytes) } + #[tokio::test] + async fn read_media_crop_read_keeps_full_wire_budget_beyond_detail_budget() { + // Crop reads bypass the detail delivery budget entirely (the + // crop_applied arm picks MAX_WIRE_IMAGE_BYTES), so even at + // detail=auto a crop may deliver well past the ~256KB auto/low + // budget as long as it fits the full 5 MiB wire budget. + let dir = tempdir().unwrap(); + let img_path = dir.path().join("photo.png"); + let png_data = create_noise_png(1600, 1200); + assert!( + png_data.len() > READ_IMAGE_BYTE_BUDGET, + "test image must start over the auto/low delivery budget" + ); + std::fs::write(&img_path, &png_data).unwrap(); + + let mut ctx = ToolContext::new(dir.path()); + ctx.route_capabilities.image_input = CapabilityState::Supported; + + let tool = ReadMediaTool; + let rich = tool + .execute_rich( + json!({ + "path": "photo.png", + "detail": "auto", + "crop": { "x": 0, "y": 0, "width": 800, "height": 1200 } + }), + &ctx, + ) + .await + .unwrap(); + assert!(rich.success); + assert_eq!(rich.metadata.as_ref().unwrap()["cropped"], true); + + let ToolResultContentBlock::Image { data, .. } = &rich.content_blocks[0]; + let payload_len = BASE64.decode(data).unwrap().len(); + assert!( + payload_len > READ_IMAGE_BYTE_BUDGET, + "a crop read must keep the full wire budget instead of the {}-byte detail budget (got {payload_len})", + READ_IMAGE_BYTE_BUDGET + ); + assert!( + payload_len <= MAX_WIRE_IMAGE_BYTES, + "delivered payload {payload_len} must still fit the {}-byte wire budget", + MAX_WIRE_IMAGE_BYTES + ); + } + #[tokio::test] async fn read_media_photo_over_budget_arrives_under_budget_as_jpeg() { let dir = tempdir().unwrap(); From 95fbd6ebc06fdcf3cd74ae628d3be6524678c646 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 18:30:55 +0800 Subject: [PATCH 30/53] fix(tools): frame the read_media default budget as a budget 'deliver about 256KB' read as a typical delivered size; small images pass untouched at far less and the ladder delivers at most the budget. Use the same budget framing the low clause and docs already use. Signed-off-by: asto --- crates/tui/src/tools/read_media.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/read_media.rs b/crates/tui/src/tools/read_media.rs index bfaf585c3c..83caa98ba2 100644 --- a/crates/tui/src/tools/read_media.rs +++ b/crates/tui/src/tools/read_media.rs @@ -214,7 +214,7 @@ impl ToolSpec for ReadMediaTool { } fn description(&self) -> &'static str { - "Read an image file (PNG, JPEG, GIF, WebP) into context for multimodal/vision inspection, with optional crop region and detail level. Default reads (detail 'auto' or 'low') deliver about 256KB; 'high'/'original' and crop reads keep the full 5 MiB payload limit. Safe, bounded decode with pixel and byte guards." + "Read an image file (PNG, JPEG, GIF, WebP) into context for multimodal/vision inspection, with optional crop region and detail level. Default reads (detail 'auto' or 'low') fit a ~256KB delivery budget; 'high'/'original' and crop reads keep the full 5 MiB payload limit. Safe, bounded decode with pixel and byte guards." } fn input_schema(&self) -> Value { @@ -251,7 +251,7 @@ impl ToolSpec for ReadMediaTool { "detail": { "type": "string", "enum": ["auto", "low", "high", "original"], - "description": "Resolution/detail preference. 'auto' (default) downscales large images to max 2048px and delivers about 256KB; 'low' to max 1024px with the same 256KB delivery budget; 'high' / 'original' preserves resolution up to max 4096px within the full 5 MiB payload limit." + "description": "Resolution/detail preference. 'auto' (default) downscales large images to max 2048px and fits the same ~256KB delivery budget as 'low' (max 1024px); 'high' / 'original' preserves resolution up to max 4096px within the full 5 MiB payload limit." } }, "required": ["path"] From bf1ab736c76a002d552a0fd3d4bc87282a426b54 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 18:30:55 +0800 Subject: [PATCH 31/53] docs(prompts): name the harness and project-instruction fragments precisely The volatile-content-last enumeration omitted two WorldState fragments the builder really adds (the interactive continual-harness block and the imported project-instructions fragment), and the module header's 'single text module' overstated where every compile-time constant lives. Signed-off-by: asto --- crates/tui/src/prompts.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 6d24f0d444..5949ed1038 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -1,8 +1,9 @@ #![allow(dead_code)] //! System prompt composition. //! -//! Prompts are assembled from composable layers loaded at compile time from -//! the single `text` module: +//! Prompts are assembled from composable layers loaded at compile time — +//! most from the `text` module, with the authority recap and the locale +//! bookends defined here: //! constitution (personality folded in) → `message[0]` (byte-stable). //! approval policy → request-time runtime metadata. //! Tool availability comes only from the per-turn model catalog. @@ -1104,8 +1105,9 @@ pub fn system_prompt_for_mode_with_context( /// 1. composed constitution prefix (compile-time constant, plus /// workspace-static project context and the skills-dir-static skills /// block) -/// 2. WorldState fragments (environment/memory/goal, permissions, route, -/// handoff) that can `render_diff` mid-session +/// 2. WorldState fragments (environment/memory/goal, the interactive +/// continual-harness block, permissions, route, handoff, and imported +/// project instructions) that can `render_diff` mid-session /// 3. per-request trailers: authority recap, then locale closer /// /// The compaction relay is not a system block: automatic compaction owns its From c27064ace23615fbb46bbf49119e8512b4ff9564 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 21:13:54 +0800 Subject: [PATCH 32/53] fix(tools): state the task clamp type-error boundary "Out-of-range values are clamped, not rejected" was false for negative integers: they are rejected as a type mismatch by the u64 parser and never reach the clamp. State the boundary per field instead (clamped into the declared range; below-1 clamp for the attempt fields), and pin the corrected sentences plus the reviewed authority claims (mode blocks not lifted by auto_approve, durable-task allow-shell fallback, trust mode full-access retry, BLOCKED gate refusal) that had no schema pin. Signed-off-by: asto --- crates/tui/src/tools/tasks.rs | 87 +++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tools/tasks.rs b/crates/tui/src/tools/tasks.rs index 9494d18b11..02ce9ca866 100644 --- a/crates/tui/src/tools/tasks.rs +++ b/crates/tui/src/tools/tasks.rs @@ -223,7 +223,7 @@ impl ToolSpec for TasksTool { ); properties.insert( "timeout_ms".to_string(), - json!({ "type": "integer", "minimum": 1000, "maximum": 600000, "description": "Kill deadline for the gate command in milliseconds. Defaults to 120000; out-of-range values are clamped, not rejected. On expiry the process group is killed, the result reports timed_out with no exit code, and any partial output is kept. (action=gate_run)" }), + json!({ "type": "integer", "minimum": 1000, "maximum": 600000, "description": "Kill deadline for the gate command in milliseconds. Defaults to 120000; values outside the declared range are clamped into it when the gate runs — negative or non-integer values are rejected as a type error before that. On expiry the process group is killed, the result reports timed_out with no exit code, and any partial output is kept. (action=gate_run)" }), ); properties.insert( "attempt_group_id".to_string(), @@ -231,11 +231,11 @@ impl ToolSpec for TasksTool { ); properties.insert( "attempt_index".to_string(), - json!({ "type": "integer", "minimum": 1, "description": "This attempt's position within its group (1-based), shown as 'index/count' in the task timeline. Defaults to 1; out-of-range values are clamped, not rejected. (action=pr_attempt_record)" }), + json!({ "type": "integer", "minimum": 1, "description": "This attempt's position within its group (1-based), shown as 'index/count' in the task timeline. Defaults to 1; values below 1 are clamped to 1, and negative or non-integer values are rejected as a type error. (action=pr_attempt_record)" }), ); properties.insert( "attempt_count".to_string(), - json!({ "type": "integer", "minimum": 1, "description": "Total number of attempts intended for the group, shown as 'index/count' in the task timeline. Defaults to 1; out-of-range values are clamped, not rejected. (action=pr_attempt_record)" }), + json!({ "type": "integer", "minimum": 1, "description": "Total number of attempts intended for the group, shown as 'index/count' in the task timeline. Defaults to 1; values below 1 are clamped to 1, and negative or non-integer values are rejected as a type error. (action=pr_attempt_record)" }), ); properties.insert( "summary".to_string(), @@ -256,7 +256,7 @@ impl ToolSpec for TasksTool { ); properties.insert( "limit".to_string(), - json!({ "type": "integer", "minimum": 1, "maximum": 100, "default": 20, "description": "Maximum number of tasks returned, newest first. Out-of-range values are clamped, not rejected. (action=list)" }), + json!({ "type": "integer", "minimum": 1, "maximum": 100, "default": 20, "description": "Maximum number of tasks returned, newest first. Values outside the declared range are clamped into it, and negative or non-integer values are rejected as a type error. (action=list)" }), ); json!({ "type": "object", @@ -1411,6 +1411,85 @@ mod tests { ); } + #[test] + fn task_range_clamps_describe_the_type_error_boundary() { + // Negative integers never reach the clamps (the u64 parser rejects + // them as a type mismatch), so a blanket "clamped, not rejected" + // would be false; each description must state the boundary instead. + let schema = TasksTool::new("tasks").input_schema(); + let timeout = schema["properties"]["timeout_ms"]["description"] + .as_str() + .expect("timeout_ms description"); + assert!(timeout.contains("clamped into it"), "{timeout}"); + assert!(timeout.contains("rejected as a type error"), "{timeout}"); + assert!(!timeout.contains("clamped, not rejected"), "{timeout}"); + for field in ["attempt_index", "attempt_count"] { + let description = schema["properties"][field]["description"] + .as_str() + .expect("attempt field description"); + assert!( + description.contains("values below 1 are clamped to 1"), + "{description}" + ); + assert!( + description.contains("rejected as a type error"), + "{description}" + ); + assert!( + !description.contains("clamped, not rejected"), + "{description}" + ); + } + let limit = schema["properties"]["limit"]["description"] + .as_str() + .expect("limit description"); + assert!(limit.contains("clamped into it"), "{limit}"); + assert!(limit.contains("rejected as a type error"), "{limit}"); + assert!(!limit.contains("clamped, not rejected"), "{limit}"); + } + + #[test] + fn task_authority_copy_pins_the_reviewed_semantics() { + // Each sentence below was corrected by a review round against the + // execution path; pin the exact claims so a revert cannot slip back + // into the schema silently. + let schema = TasksTool::new("tasks").input_schema(); + let mode = schema["properties"]["mode"]["description"] + .as_str() + .expect("mode description"); + assert!( + mode.contains("plan's read-only blocks are not lifted by auto_approve"), + "{mode}" + ); + let allow_shell = schema["properties"]["allow_shell"]["description"] + .as_str() + .expect("allow_shell description"); + assert!( + allow_shell + .contains("omitting falls back to the configured durable-task allow-shell setting"), + "{allow_shell}" + ); + assert!( + allow_shell.contains("which is false unless shell execution is enabled in config"), + "{allow_shell}" + ); + let trust_mode = schema["properties"]["trust_mode"]["description"] + .as_str() + .expect("trust_mode description"); + assert!( + trust_mode.contains("retried with full access instead of being denied"), + "{trust_mode}" + ); + let auto_approve = schema["properties"]["auto_approve"]["description"] + .as_str() + .expect("auto_approve description"); + assert!(auto_approve.contains("Defaults to false"), "{auto_approve}"); + let command = schema["properties"]["command"]["description"] + .as_str() + .expect("command description"); + assert!(command.contains("refused with BLOCKED"), "{command}"); + } + #[test] fn gate_classifier_detects_timeout() { assert_eq!( From da8a0276f463512f7089f92d5490b372403913a5 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 21:13:54 +0800 Subject: [PATCH 33/53] fix(tools): label the read_media size line as encoded payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receipt printed the pre-base64 encoded size as "(wire payload)", but the actual wire payload is the base64 form (~4/3 larger) — the same doc the budget bullet cites says so. Name it honestly. Signed-off-by: asto --- crates/tui/src/tools/read_media.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/tools/read_media.rs b/crates/tui/src/tools/read_media.rs index 83caa98ba2..87644fea1a 100644 --- a/crates/tui/src/tools/read_media.rs +++ b/crates/tui/src/tools/read_media.rs @@ -401,7 +401,7 @@ pub(crate) async fn execute_read_media( Crop: {crop_summary}\n\ Detail: {}\n\ Delivery: {delivery_note}\n\ - Size: {} (source) -> {} (wire payload)", + Size: {} (source) -> {} (encoded payload)", detail_mode.as_str(), human_bytes(processed.source_bytes), human_bytes(processed.encoded_bytes), From 0b09401aa6f9a62d137a493b15673d67477bdccd Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 21:14:05 +0800 Subject: [PATCH 34/53] docs(prompts): finish the stale header-comment residuals Three comments survived the assembly-layer cleanup with stale claims: the section header still said every bundled layer lives in text.rs (the authority recap and locale bookends are defined here), the handoff doc still said the relay artifact is prepended to the system prompt (it is routed as the late WorldState token-budget fragment), and the environment-block doc still claimed placement before instructions and skills (it leads the workspace WorldState fragment, after the constitution prefix). Mirror the corrected module header in text.rs. Signed-off-by: asto --- crates/tui/src/prompts.rs | 22 +++++++++++++--------- crates/tui/src/prompts/text.rs | 5 +++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 5949ed1038..6d385e97e8 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -83,8 +83,9 @@ impl Default for PromptSessionContext<'_> { /// Conventional location for the structured session relay artifact (#32). /// A previous session writes it on exit / `/compact`; the next session reads -/// it back on startup and prepends it to the system prompt so a fresh agent -/// doesn't have to re-discover open blockers from scratch. +/// it back on startup and routes it as the late WorldState token-budget / +/// continuity fragment (`load_handoff_block`) so a fresh agent doesn't have +/// to re-discover open blockers from scratch. pub const HANDOFF_RELATIVE_PATH: &str = ".codewhale/handoff.md"; /// Legacy handoff path for reading from existing installs. `pub(crate)` so /// command surfaces that teach the fallback name it from the same constant @@ -190,9 +191,10 @@ fn translation_target_language_for_tag(locale_tag: &str) -> &'static str { /// Render a `## Environment` block listing the resolved locale tag and the /// actionable host facts that affect command syntax. /// -/// The block is appended to the workspace-static portion of the system -/// prompt (after the shared constitution + project context, before configured -/// instructions / skills). `locale_tag` is resolved by the caller from +/// The block leads the workspace WorldState fragment (environment + memory / +/// goal facts), which is assembled after the cache-stable constitution +/// prefix so a change re-renders as a fragment diff instead of rebuilding +/// the static prefix. `locale_tag` is resolved by the caller from /// `Settings` so this function stays I/O-free. /// /// `platform` and `shell` remain because they change how commands must be @@ -402,10 +404,12 @@ fn user_constitution_disabled_by_setup_state() -> bool { // ── Prompt layers loaded at compile time ────────────────────────────── // -// Every bundled prompt layer lives in `prompts/text.rs` as a compile-time -// constant (consolidated from the retired per-layer `prompts/*.md` files; -// each constant is byte-identical to the file it replaced, trailing newline -// included). The constants are re-exported here so the existing +// Every bundled prompt layer is a compile-time constant — most in +// `prompts/text.rs`, with the authority recap and the locale bookends +// defined in this file (consolidated from the retired per-layer +// `prompts/*.md` files; each constant is byte-identical to the file it +// replaced, trailing newline included). The constants are re-exported here +// so the existing // `crate::prompts::NAME` paths used across the crate are unchanged. Edit // prompt text in `text.rs` directly; the test suite below guards content // and ordering invariants (constitution structure and binding gates #4032, diff --git a/crates/tui/src/prompts/text.rs b/crates/tui/src/prompts/text.rs index 101d999801..044140c164 100644 --- a/crates/tui/src/prompts/text.rs +++ b/crates/tui/src/prompts/text.rs @@ -1,5 +1,6 @@ -//! Compile-time prompt text — the single source of truth for every bundled -//! layer of the Codewhale system prompt. +//! Compile-time prompt text — most bundled layers of the Codewhale system +//! prompt live here as constants (the authority recap and the locale +//! bookends are defined in `prompts.rs`). //! //! Each constant below used to live in its own `prompts/*.md` file, pulled in //! with `include_str!`. The per-layer file sprawl (17 files across 4 From bcb0dae8483ff2a20f0b6a7a834bf34928e237fe Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 21:14:05 +0800 Subject: [PATCH 35/53] test(tools): pin fim anchor counting edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the emergent counting semantics the refusal trio left unpinned: an empty prefix is refused with the char-boundary count (characters + 1, not bytes + 1 — the multi-byte fixture discriminates a byte-offset refactor), suffix occurrences before the prefix are invisible to the uniqueness check, and a suffix that only occurs before the prefix stays SuffixNotFound. Signed-off-by: asto --- crates/tui/src/tools/fim.rs | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/tui/src/tools/fim.rs b/crates/tui/src/tools/fim.rs index 294e0e3757..c4241bb905 100644 --- a/crates/tui/src/tools/fim.rs +++ b/crates/tui/src/tools/fim.rs @@ -297,4 +297,63 @@ mod tests { "unique anchors must reach the API boundary: {message}" ); } + + #[tokio::test] + async fn empty_prefix_anchor_is_refused_with_char_boundary_count() { + // An empty anchor matches at every char boundary including EOF, so + // the count is characters + 1 — not bytes + 1. The multi-byte + // fixture pins the char-boundary semantics against a byte-offset + // refactor ("hé\n" = 3 chars / 4 bytes → 4 locations, not 5). + let (_tmp, context) = workspace_with("hé\n"); + let message = execution_error( + serde_json::json!({ + "path": "sample.txt", + "prefix_anchor": "", + "suffix_anchor": "é", + }), + &context, + ) + .await; + assert!(message.contains("matches 4 locations"), "{message}"); + assert!(message.contains("exactly once"), "{message}"); + } + + #[tokio::test] + async fn suffix_occurrences_before_the_prefix_do_not_count() { + // Only the region after the prefix participates in the uniqueness + // check: an earlier occurrence is invisible, and one suffix after + // the prefix passes validation. + let (_tmp, context) = workspace_with("return 1\nhead\nmid\nreturn 1\ntail\n"); + let message = execution_error( + serde_json::json!({ + "path": "sample.txt", + "prefix_anchor": "head", + "suffix_anchor": "return 1", + }), + &context, + ) + .await; + assert!( + message.contains("FIM API client not available"), + "one suffix after the prefix must pass validation: {message}" + ); + } + + #[tokio::test] + async fn suffix_only_before_the_prefix_is_not_found() { + let (_tmp, context) = workspace_with("return 1\nhead\nmid\ntail\n"); + let message = execution_error( + serde_json::json!({ + "path": "sample.txt", + "prefix_anchor": "head", + "suffix_anchor": "return 1", + }), + &context, + ) + .await; + assert!( + message.contains("Suffix anchor not found after prefix anchor"), + "{message}" + ); + } } From 8d0e997cbeb08f830b783d1c0716d90bb3e0e5a6 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 21:14:05 +0800 Subject: [PATCH 36/53] test(tools): pin background gate task-id null cases The visible background copy promises a task id per gate, null when a gate is skipped or fails to start; only the running arm had coverage. Drive both null arms directly through start_background_gates: a gate without a program reports skipped with its reason, and a gate whose working directory does not exist fails shell-manager validation and reports failed_to_start with the accurate error. Signed-off-by: asto --- crates/tui/src/tools/verifier.rs | 65 ++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/tui/src/tools/verifier.rs b/crates/tui/src/tools/verifier.rs index 4f86376bd3..429be1f98e 100644 --- a/crates/tui/src/tools/verifier.rs +++ b/crates/tui/src/tools/verifier.rs @@ -1588,4 +1588,69 @@ mod tests { output.stdout ); } + + #[tokio::test] + async fn run_verifiers_background_reports_null_task_ids_for_skipped_and_failed_gates() { + // The background copy promises "a task id per gate, null when a gate + // is skipped or fails to start". Pin both null arms: a gate without + // a program is skipped before spawn, and a gate whose working + // directory does not exist fails validation inside the shell + // manager, reporting failed_to_start with the accurate error. + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path()); + + let missing = tmp.path().join("definitely-missing-cwd"); + let failed = start_background_gates( + &ctx, + VerifierProfile::Auto, + VerifierLevel::Quick, + vec![VerifierGate { + name: "no-such-cwd".to_string(), + ecosystem: "custom".to_string(), + cwd: missing, + program: Some("true".to_string()), + args: vec![], + env: vec![], + skipped_reason: None, + }], + ) + .expect("background result"); + let parsed: RunVerifiersBackgroundOutput = + serde_json::from_str(&failed.content).expect("background output json"); + assert_eq!(parsed.failed_to_start, 1, "{}", parsed.summary); + assert!(!parsed.success); + let job = &parsed.jobs[0]; + assert_eq!(job.status, "failed_to_start"); + assert!( + job.task_id.is_none(), + "a failed-to-start gate must carry no task id" + ); + let error = job.error.as_deref().expect("failure reason"); + assert!(error.contains("unavailable"), "{error}"); + + let skipped = start_background_gates( + &ctx, + VerifierProfile::Auto, + VerifierLevel::Quick, + vec![VerifierGate { + name: "not-applicable".to_string(), + ecosystem: "custom".to_string(), + cwd: tmp.path().to_path_buf(), + program: None, + args: vec![], + env: vec![], + skipped_reason: Some("tooling not installed".to_string()), + }], + ) + .expect("background result"); + let parsed: RunVerifiersBackgroundOutput = + serde_json::from_str(&skipped.content).expect("background output json"); + let job = &parsed.jobs[0]; + assert_eq!(job.status, "skipped"); + assert!( + job.task_id.is_none(), + "a skipped gate must carry no task id" + ); + assert_eq!(job.skipped_reason.as_deref(), Some("tooling not installed")); + } } From bfee933776f173b797cbab73225c5e7d6ad0fdc4 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 21:14:15 +0800 Subject: [PATCH 37/53] test(tools): pin the load-bearing copy sentences The Run schema's per-path shell scoping and background task-id copy, the bash tool's merged-stream/fresh-shell claims, and the read tool's image/decoding disclosures had no test anywhere, so a full revert of any of them was invisible to CI. Pin each corrected sentence (and the absences: no task_ids array in the visible background copy, no separate-stream promise, no text-only read claim) against the schema and description strings. Signed-off-by: asto --- crates/tui/src/tools/file_tool/tests.rs | 31 ++++++++++++++++++++ crates/tui/src/tools/run_tool.rs | 38 +++++++++++++++++++++++++ crates/tui/src/tools/shell/tests.rs | 25 ++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/crates/tui/src/tools/file_tool/tests.rs b/crates/tui/src/tools/file_tool/tests.rs index 43b01933eb..b0391e794a 100644 --- a/crates/tui/src/tools/file_tool/tests.rs +++ b/crates/tui/src/tools/file_tool/tests.rs @@ -6,6 +6,37 @@ fn tool() -> FileTool { FileTool::with_patch("File") } +#[test] +fn read_description_pins_the_disclosed_boundaries() { + // Each clause was corrected by the model-facing-copy audit against + // file.rs / image_attach.rs; pin the exact claims so a revert cannot + // restate the old "text-only" description silently. + let description = ReadTool.description(); + assert!(description.starts_with("Read a file."), "{description}"); + assert!( + description.contains("PNG, JPEG, GIF, and WebP files up to 5 MiB are returned as images"), + "{description}" + ); + assert!( + description.contains( + "BMP files and images above 5 MiB are detected but replaced with an omission note" + ), + "{description}" + ); + assert!( + description.contains("bytes that are not valid UTF-8 become replacement characters"), + "{description}" + ); + assert!( + description.contains("2000 complete lines or 50KB, whichever comes first"), + "{description}" + ); + assert!( + !description.contains("Read a text file"), + "the read tool must not claim to be text-only: {description}" + ); +} + #[tokio::test] async fn lowercase_read_returns_a_typed_image_block() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tools/run_tool.rs b/crates/tui/src/tools/run_tool.rs index c4f33013fd..b298b89f72 100644 --- a/crates/tui/src/tools/run_tool.rs +++ b/crates/tui/src/tools/run_tool.rs @@ -207,4 +207,42 @@ mod tests { .collect(); assert_eq!(advertised, RunTool::ACTIONS); } + + #[test] + fn run_verifier_copy_pins_the_per_path_semantics() { + // These sentences were corrected by review rounds against the actual + // spawn paths (foreground direct spawn vs. background shell render); + // pin them so a revert cannot restate a falsehood silently. + let schema = RunTool::new("Run").input_schema(); + let commands = schema["properties"]["commands"]["description"] + .as_str() + .expect("commands description"); + assert!( + commands.contains("Foreground gates run directly as program+args, not through a shell"), + "{commands}" + ); + assert!( + commands + .contains("Background gates are rendered into a single command line and run through the detected platform shell"), + "{commands}" + ); + assert!( + commands.contains("a POSIX-quoted path can fail under PowerShell"), + "{commands}" + ); + let background = schema["properties"]["background"]["description"] + .as_str() + .expect("background description"); + assert!( + background + .contains("a task id per gate, null when a gate is skipped or fails to start"), + "{background}" + ); + assert!( + !background.contains("task_ids"), + "the visible payload carries per-gate jobs, not a task_ids array: {background}" + ); + let items = &schema["properties"]["commands"]["items"]; + assert_eq!(items["additionalProperties"], json!(false)); + } } diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 5be3a01e37..a7ac5cd6c6 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -41,6 +41,31 @@ fn lowercase_bash_schema_is_small_contract() { assert!(!BashTool::new("Bash").model_visible()); } +#[test] +fn lowercase_bash_description_pins_the_stream_and_persistence_claims() { + // These sentences were corrected by the model-facing-copy audit against + // the contract spawn path (shared_output_pipe + finish_contract_bash_result + // concatenation, one Command per call); pin them so a revert cannot + // restate separate-stream or persistent-shell semantics silently. + let description = LowercaseBashTool.description(); + assert!( + description.contains("stdout and stderr arrive as one merged stream in arrival order"), + "{description}" + ); + assert!( + description.contains("redirect a command's stderr to a file"), + "{description}" + ); + assert!( + description.contains("Each call runs in a fresh shell"), + "{description}" + ); + assert!( + !description.contains("return stdout and stderr"), + "the contract merges both streams; no separate-stream promise may return: {description}" + ); +} + #[test] fn lowercase_bash_description_matches_the_timeout_it_actually_applies() { use super::{ From 5add71c4a34c45d2b8e0e21bd76230f07760d079 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 21:14:15 +0800 Subject: [PATCH 38/53] test(skills): anchor the gen-12 superseded digests to fixtures The shape test only rejects a digest equal to the current body, so a single wrong hex digit in one of the four generation-13 entries would silently leave real generation-12 installs unrefreshed. Embed the four previous bodies as fixtures (the same pattern as the v4 legacy pin and the generation-10 documents body) and hash each against its registered entry on every run; the behavioral supersede check rides along. Signed-off-by: asto --- .../skills/system/fixtures/gen12-best-of-n.md | 112 +++++++++++++++++ .../skills/system/fixtures/gen12-delegate.md | 89 ++++++++++++++ .../system/fixtures/gen12-fleet-manager.md | 110 +++++++++++++++++ .../system/fixtures/gen12-skill-creator.md | 113 ++++++++++++++++++ crates/tui/src/skills/system/tests.rs | 43 +++++++ 5 files changed, 467 insertions(+) create mode 100644 crates/tui/src/skills/system/fixtures/gen12-best-of-n.md create mode 100644 crates/tui/src/skills/system/fixtures/gen12-delegate.md create mode 100644 crates/tui/src/skills/system/fixtures/gen12-fleet-manager.md create mode 100644 crates/tui/src/skills/system/fixtures/gen12-skill-creator.md diff --git a/crates/tui/src/skills/system/fixtures/gen12-best-of-n.md b/crates/tui/src/skills/system/fixtures/gen12-best-of-n.md new file mode 100644 index 0000000000..62e2813919 --- /dev/null +++ b/crates/tui/src/skills/system/fixtures/gen12-best-of-n.md @@ -0,0 +1,112 @@ +--- +name: best-of-n +description: Generate a small set of independent candidate solutions in worktrees, judge them against one explicit rubric, and apply the winner only after PASS verification. +metadata: + short-description: Compare independent candidates +--- + +# Best of N + +Use this skill when a consequential design, implementation, explanation, or +debugging task has several plausible solutions and comparison is worth the +extra model work. In Operate mode this is the preferred ensemble pattern for +high-stakes or ambiguous approaches. Do not use it for a tiny change or when +the user has already chosen the approach. + +## Set The Tournament + +1. Define one task, one evidence packet, and one explicit scoring rubric before + launching candidates. Include correctness, fit to the request, simplicity, + risk, and verification. +2. Choose `N` from 2 to 4 for a quick comparison (default 3). For an explicit + experimental search, use the Workflow search option: 2–16 live candidates, + with larger validated populations queued at the Workflow host's 16-worker + concurrency gate rather than launched at once. +3. Give every candidate the same task and rubric. Add only a candidate number; + do not steer candidates toward different conclusions unless diversity is an + explicit part of the request. +4. When the tournament spans more than one parent turn, prefer a session goal + only when the user explicitly set one via `/goal` — `create_goal` is + reserved for explicit goal requests, so never infer a goal from the + tournament itself. If `create_goal` is in your tool list, work against the + existing goal; if it is not, run `tool_search` first to activate it. In + subagent sessions `create_goal` does not exist and `tool_search` cannot + surface it — track progress in your own notes instead. + +## Generate Independently + +Start the candidates as parallel `agent` workers and return agent_ids +immediately so the parent stays free. For proposals, reviews, or research, keep +them read-only: + +```json +{ + "action": "start", + "name": "candidate_1", + "prompt": "Produce candidate 1 for the task below. Return the proposal, evidence, risks, and rubric self-score. Do not edit files.\n\n", + "type": "explore" +} +``` + +Launch the remaining candidates with the same contract, then use `agent` wait +or completion events to collect every result. Do not show one candidate another +candidate's answer before generation finishes. + +When candidates must implement code, give each one: + +- `type: "implement"` +- `worktree: true` +- the same bounded `write_roots` when the task names target paths — worktree + isolation is what keeps parallel builders from colliding, and candidates + must all be able to reach the task's files + +Never run parallel writers in the parent checkout. Each builder must return +the structured candidate contract (candidate id, hypothesis, paths, commands, +self-verdict, risks, and artifact references). A self-verdict is evidence to +inspect, not a hard-gate result. + +Optional diversity: candidates sharing one `type` resolve the same single +`[subagents]` per-role model route, so get spread from prompt angles (or +distinct roles), not from per-candidate model choices; `model` is not +advertised on this call surface. + +## Judge Once + +Use one read-only reviewer worker, or the parent when the result is small, to +score all candidates against the original rubric. The judge must: + +- cite evidence from each candidate rather than vote by style; +- reject candidates that violate authority, scope, or verification gates; +- treat candidate-reported commands and PASS claims as untrusted until replay; +- name the winner and the decisive reasons; +- identify useful pieces worth combining, if any; +- say when the candidates are tied or all fail. + +Do not ask candidates to vote for themselves. Do not silently merge incompatible +approaches into a new unreviewed solution. + +## Integrate Only After PASS + +For proposal-only work, return the winning answer with a compact score summary. +For code work: + +1. Freeze the baseline, evaluator, hard gates, score rule, and authority before + a larger search admits candidates. Any evaluator change starts a revision. +2. After a worker loses write authority, apply its patch to a clean baseline + and let the runtime—not that worker—run hard gates and scoring. +3. Inspect the winning worktree diff and independently replay it on the clean + baseline. A different read-only model may look for gaming, but deterministic + tests remain the authority. +4. Present the verified winner for review. Applying or merging is a separate, + explicit user action; `NONE` is valid when every candidate fails. +5. Preserve losing and failed candidate receipts as useful negative results. + +The checked-in `operate_best_of_n.workflow.js` recipe supports +`strategy: "search"` for structured 2–16 candidate generation and review. It +does **not** yet turn prompt-listed commands into hidden runtime gates. Do not +advertise those gates until the runtime evaluator host consumes a frozen +`WorkflowSearchSpec`. + +Stop early when one candidate reveals a hard constraint that invalidates the +tournament. Report the negative result rather than spending the remaining +budget to manufacture variety. diff --git a/crates/tui/src/skills/system/fixtures/gen12-delegate.md b/crates/tui/src/skills/system/fixtures/gen12-delegate.md new file mode 100644 index 0000000000..9576baa869 --- /dev/null +++ b/crates/tui/src/skills/system/fixtures/gen12-delegate.md @@ -0,0 +1,89 @@ +--- +name: delegate +description: Strategic delegation for multi-step coding, research, or verification work. Use when a task can be split into parent reasoning plus focused sub-agent execution through the agent tool. +metadata: + short-description: Delegate focused work to sub-agents +--- + +# Delegate + +Use sub-agents when they can do focused work in parallel while the parent keeps architectural judgment, integration, and final verification. + +## Keep vs Delegate + +Keep in the parent: + +- Understanding the user's actual request and constraints +- Architecture, security, product, and release-risk decisions +- Cross-module integration +- Final review, test interpretation, and user-facing summary + +Delegate to sub-agents: + +- Read-only exploration over a bounded file set +- Mechanical edits with a clear file ownership boundary +- Focused test or lint runs +- Boilerplate generation from an explicit spec +- Independent checks that can run while parent work continues + +Do not delegate tiny one-step tasks, ambiguous product decisions, destructive operations without a clear acceptance criterion, or final verification. + +## Launch Focused Work + +Use `agent` for a focused child run. Launch independent children together so they can run in parallel. + +Children inherit the active model by default, including `type: "explore"`; prefer provider-neutral routing (the operator's `[subagents]` per-role model configuration) over hardcoded model ids in prompts or Fleet config: + +```json +{ + "action": "start", + "name": "config_audit", + "prompt": "Inspect crates/tui/src/config.rs and crates/tui/src/settings.rs for duplicate model-default logic. Return file/line findings only; do not edit files.", + "type": "explore" +} +``` + +For code changes, give the child a precise write boundary and tell it not to revert unrelated edits: + +```json +{ + "action": "start", + "name": "docs_patch", + "prompt": "Update only docs/CONFIGURATION.md to document the new [tui] keys. Match the surrounding style. Do not edit other files.", + "type": "implement", + "write_roots": ["docs/CONFIGURATION.md"] +} +``` + +Keep each child's `prompt` self-contained: pass only the context that child needs rather than the whole conversation. + +## Evaluate and Verify + +Sub-agent outputs are self-reports. Re-check material claims before relying on them: + +- Read changed files directly. +- Run the relevant tests locally. +- Inspect unexpected diffs before committing. +- Verify externally visible or destructive claims against source data. + +## Prompt Shape + +A good delegation prompt includes: + +- The exact task +- Files or modules owned by the child +- Files or behavior the child must not touch +- Expected output format +- Acceptance criteria + +Weak prompt: + +```text +Fix the settings bug. +``` + +Strong prompt: + +```text +Own only crates/tui/src/settings.rs and its tests. Preserve existing config key names. Add a regression test showing that provider-specific API key changes do not restart onboarding. Return the changed paths and test command output. +``` diff --git a/crates/tui/src/skills/system/fixtures/gen12-fleet-manager.md b/crates/tui/src/skills/system/fixtures/gen12-fleet-manager.md new file mode 100644 index 0000000000..42c4616927 --- /dev/null +++ b/crates/tui/src/skills/system/fixtures/gen12-fleet-manager.md @@ -0,0 +1,110 @@ +--- +name: fleet-manager +description: Use when managing, triaging, restarting, escalating, or summarizing Codewhale fleet runs and workers. +metadata: + short-description: Triage Codewhale fleet runs +--- + +# Fleet Manager + +Use this skill when acting as a manager agent for Codewhale fleet runs. +Your job is to classify worker state, choose the narrowest safe typed action, +and leave a ledgered receipt or a safe escalation draft. + +## Authority Boundary + +- Prefer typed fleet surfaces over shell spelunking: `codewhale fleet status`, + `inspect`, `logs`, `artifacts`, `interrupt`, `restart`, `resume`, `stop` + (stop requires `--all`), and the Runtime API endpoints. +- Do not read `.codewhale/fleet.jsonl`, host logs, or remote files directly + unless the typed command or API is missing required evidence. +- Do not send Slack, webhook, PagerDuty, email, or chat messages unless the + user or run config explicitly authorizes sending. Draft the message instead. +- Never include secrets, tokens, webhook URLs, routing keys, full prompts, or + oversized logs in a summary or escalation. + +## Triage Loop + +1. Identify the run and worker from the user request, run receipt, or fleet + status output. If no worker is named, start with `codewhale fleet status`. +2. Inspect the worker with `codewhale fleet inspect ` or the + matching Runtime API worker endpoint (`GET /v1/fleet/workers/{worker_id}`). +3. Review bounded evidence with `codewhale fleet logs ` and + `codewhale fleet artifacts `, or the Runtime API equivalents + (`GET /v1/fleet/runs/{run_id}/receipts/{task_id}/evidence` and + `GET /v1/fleet/runs/{run_id}/events/replay`). Summarize artifact refs, + not full payloads. +4. Classify the state before acting: + - `transient failure`: transport error, timeout, stale heartbeat, host + unavailable, or retryable provider/network failure. + - `task failure`: worker completed the task but the result is wrong, + missing required artifacts, or reports a domain error. + - `verifier failure`: scorer/verifier failed or disagrees with the worker + result. + - `needs-human`: missing authority, unsafe secret boundary, destructive + action, repeated restart exhaustion, ambiguous product decision, or + conflict between artifacts and verifier. +5. Choose one typed action: + - run has orphaned leases after a manager restart: + `codewhale fleet resume ` (idempotent reconcile). + - transient and retry budget remains: `codewhale fleet restart `. + - transient but unsafe to retry: draft escalation and mark needs-human. + - task failure: preserve artifacts, summarize the failure, and avoid restart + unless the task spec says retrying can produce new evidence. + - verifier failure: inspect scorer inputs and artifacts, then escalate if the + verifier cannot be corrected through a typed action. + - needs-human: do not restart automatically; draft a concise escalation. +6. Record the result in the response: classification, action taken or drafted, + evidence commands, artifact refs, and next owner. + +## Restart vs Escalate + +Restart only when all of these are true: + +- the failure is likely transient, +- the task is idempotent or the run policy allows retry, +- retry budget remains, +- no secret, permission, or destructive action boundary is involved, and +- the previous attempt produced enough receipt data to explain the restart. + +Escalate when any of these are true: + +- restart budget is exhausted, +- the worker requests secrets or new authority, +- artifacts indicate data loss, corruption, or destructive side effects, +- the verifier and task result conflict in a way you cannot resolve from typed + evidence, +- the same failure repeats after a restart, or +- a human product or release decision is required. + +## Safe Escalation Draft + +Use this shape for Slack/PagerDuty drafts. Keep logs to three short lines or an +artifact ref. + +```text +Codewhale fleet needs attention +Run: +Worker: +Task: +Classification: +Reason: +Latest typed evidence: codewhale fleet inspect ; codewhale fleet artifacts +Safe log excerpt: <3 lines max or "see artifact "> +Requested decision: +``` + +## Post-Run Receipt + +End every Fleet Manager response with a compact receipt: + +```text +Fleet receipt +Run: +Workers checked: +Classification: +Action: +Ledger expectation: +Artifacts reviewed: +Follow-up owner: +``` diff --git a/crates/tui/src/skills/system/fixtures/gen12-skill-creator.md b/crates/tui/src/skills/system/fixtures/gen12-skill-creator.md new file mode 100644 index 0000000000..8e4434a7d3 --- /dev/null +++ b/crates/tui/src/skills/system/fixtures/gen12-skill-creator.md @@ -0,0 +1,113 @@ +--- +name: skill-creator +description: Create or improve Codewhale skills. Use when the user wants a new skill, wants to update an existing skill, or needs guidance on when a skill should be a skill versus MCP, hooks, tools, or a plugin scaffold. +metadata: + short-description: Create Codewhale skills +aliases-for: create-skill +--- + +# Skill Creator + +Use this skill to create small, useful Codewhale skills that match the +runtime this repository actually ships. + +## What A Skill Is + +A skill is a local folder with a `SKILL.md` file. Codewhale reads the skill name +and description during discovery, then loads the body only when the user or task +matches the skill. + +Discovery paths, in precedence order: + +- `/.agents/skills` +- `/skills` +- `/.opencode/skills` +- `/.claude/skills` +- `/.cursor/skills` +- `/.codewhale/skills` +- `/.codex/skills` (audit-compatible only; not runtime-active) +- `~/.agents/skills` +- `~/.claude/skills` +- `~/.codewhale/skills` +- `~/.deepseek/skills` (legacy fallback) +- `~/.codex/skills` (audit-compatible only; not runtime-active) + +Use skills for model instructions, workflows, and lightweight conventions. Use +MCP for live external APIs or durable tools. Use hooks for automatic local +events. Use plugin folders for versioned, trust-gated packaging — the plugin +loader activates a bundle's Skills, MCP servers, Commands, Agents, and Hooks +(see the plugin-creator skill). + +## Minimum Shape + +```text +my-skill/ +`-- SKILL.md +``` + +```markdown +--- +name: my-skill +description: Use when Codewhale should follow this specific workflow. +--- + +# My Skill + +Instructions for the agent. +``` + +Frontmatter parsing is intentionally simple. Keep `name` and `description` as +plain single-line values. Use lower-case hyphen-case names. + +## Writing Rules + +- Make the `description` action-oriented and trigger-specific. It is the main + signal Codewhale sees before loading the body. +- Keep the body operational. Include what to do, what to avoid, and how to + verify the result. +- Do not include general programming advice, marketing copy, or long background + material. +- Move bulky details to `references/` and mention exactly when to open them. +- Add `scripts/` only for deterministic helpers that are worth maintaining. +- Add `assets/` only for templates, fixtures, examples, or files reused by the + workflow. +- Do not assume scripts are safe to run. Community skill scripts require user + intent and trust review. + +## Creation Workflow + +1. Define the skill boundary in one sentence. +2. Decide whether a skill is the right surface: + - Instructional workflow: skill + - External service/API: MCP server plus an optional skill + - Repeated shell helper: local tool or script plus an optional skill + - Packaging multiple pieces: plugin scaffold plus skill/MCP activation notes +3. Create `/SKILL.md`. +4. Write frontmatter with `name` and `description`. +5. Write a concise body with: + - trigger and scope + - required inputs or assumptions + - step-by-step workflow + - validation checks + - safety notes +6. Add companion files only when they reduce real complexity. +7. Validate by loading the skill through `/skills` or by running the relevant + skill discovery tests if editing this repository. + +## Updating Existing Skills + +- Preserve the user's local intent. Avoid replacing a working skill wholesale + unless the user asked for a rewrite. +- Tighten descriptions when the skill is under-triggering or over-triggering. +- Remove stale tool names, unavailable dependencies, and copied instructions + from other agents that do not apply to Codewhale. +- Keep examples short and directly tied to this runtime's commands and tools. + +## Validation Checklist + +- `SKILL.md` starts with `---`. +- `name` matches the directory name unless there is a deliberate reason. +- `description` says when to use the skill, not just what it is. +- The body references only tools, commands, and paths that exist or are clearly + optional. +- Any scripts or external-service steps explain credential and trust handling. diff --git a/crates/tui/src/skills/system/tests.rs b/crates/tui/src/skills/system/tests.rs index 34bb10af20..7ba1f61f8a 100644 --- a/crates/tui/src/skills/system/tests.rs +++ b/crates/tui/src/skills/system/tests.rs @@ -18,6 +18,24 @@ fn marker_file(tmp: &TempDir) -> std::path::PathBuf { /// `system.rs` is checked against the real historic bytes on every run. const V4_LEGACY_BODY_FIXTURE: &str = include_str!("fixtures/v4-best-practices-legacy.md"); +/// The generation-12 bodies the four short-description deletions replaced. +/// Like the v4 fixture above, each is checked against its +/// `SUPERSEDED_SHIPPED_BODY_DIGESTS` entry on every run, so a transcription +/// error in the digest table fails the suite instead of silently +/// fossilizing real generation-12 installs. +const GEN12_BODY_FIXTURES: &[(&str, &str)] = &[ + ("best-of-n", include_str!("fixtures/gen12-best-of-n.md")), + ("delegate", include_str!("fixtures/gen12-delegate.md")), + ( + "fleet-manager", + include_str!("fixtures/gen12-fleet-manager.md"), + ), + ( + "skill-creator", + include_str!("fixtures/gen12-skill-creator.md"), + ), +]; + fn sha256_hex(bytes: &[u8]) -> String { use sha2::{Digest, Sha256}; Sha256::digest(bytes) @@ -609,6 +627,31 @@ fn upgrade_from_v4_installs_pack_and_retires_unchanged_v4_best_practices() { assert_eq!(ver.trim(), BUNDLED_SKILL_VERSION); } +#[test] +fn gen12_superseded_digests_match_the_recorded_previous_bodies() { + // The digest table alone cannot tell a transcription error from a + // correct entry (the shape test only rejects digest == current), so + // each generation-12 fixture is hashed against its registered entry + // here. A wrong hex digit now fails the suite instead of silently + // leaving real generation-12 installs unrefreshed. + for (name, fixture) in GEN12_BODY_FIXTURES { + let digest = SUPERSEDED_SHIPPED_BODY_DIGESTS + .iter() + .find(|(skill, _)| skill == name) + .map(|(_, digest)| *digest) + .unwrap_or_else(|| panic!("no superseded digest registered for {name}")); + assert_eq!( + sha256_hex(fixture.as_bytes()), + digest, + "{name}: embedded generation-12 body must match the superseded digest" + ); + assert!( + is_superseded_shipped_body(name, fixture), + "{name}: the embedded generation-12 body must be treated as superseded" + ); + } +} + #[test] fn upgrade_preserves_legacy_v4_body_plus_user_addendum() { let tmp = TempDir::new().unwrap(); From 33395ff9dc605927a160cd383cff89faee005d75 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 5 Nov 2026 00:03:29 +0800 Subject: [PATCH 39/53] fix(tools): stop teaching the hidden rlm surface in harness copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model-visible harness tool's description and its overview result's runtime block told the model to keep large source material in rlm. rlm is compatibility-only (model_visible() == false, kept out of the catalog and tool_search, and its own description says not to teach it to new model turns) — the same phantom-name class this branch fixed in the v4-best-practices skill and that the PHANTOM_NAMES sweep guards. On hosts whose allowlists derive from the wire catalog, the recommended call is rejected outright. The copy now names the visible bounded-reread surfaces instead: read with offset/limit and retrieve_tool_result. Signed-off-by: asto18089 --- crates/tui/src/tools/harness.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/harness.rs b/crates/tui/src/tools/harness.rs index 746e3523f8..6c92b4b657 100644 --- a/crates/tui/src/tools/harness.rs +++ b/crates/tui/src/tools/harness.rs @@ -54,7 +54,7 @@ impl ToolSpec for HarnessTool { } fn description(&self) -> &'static str { - "Inspect or refine the durable continual harness for this workspace. Use action=overview at the start of substantial multi-turn work to recover bounded prompt notes, reusable sub-agent briefs, and skill hints. Use action=refine only after observing concrete evidence for a reusable improvement; it stores a small project-local entry that later turns receive as untrusted working guidance. Use action=remove to retire an obsolete entry. Keep large source material in rlm, compose parallel child work with workflow task(...), and use agent action=message/followup for child coordination." + "Inspect or refine the durable continual harness for this workspace. Use action=overview at the start of substantial multi-turn work to recover bounded prompt notes, reusable sub-agent briefs, and skill hints. Use action=refine only after observing concrete evidence for a reusable improvement; it stores a small project-local entry that later turns receive as untrusted working guidance. Use action=remove to retire an obsolete entry. Keep large source material out of the transcript: read with offset/limit and retrieve_tool_result reload bounded slices on demand; compose parallel child work with workflow task(...); use agent action=message/followup for child coordination." } fn input_schema(&self) -> Value { @@ -121,7 +121,7 @@ impl ToolSpec for HarnessTool { "path": state.path, "entries": state.entries, "runtime": { - "context": "rlm keeps large context as a persistent Python variable", + "context": "read with offset/limit and retrieve_tool_result reload bounded slices of large material on demand", "orchestration": "workflow composes task(...) calls and parallel fan-out", "messaging": "agent action=message and action=followup coordinate active children", "continuation": "goal keeps an explicit durable objective active" From d6d957228969e08dbef2e5e630851d4bf47ea7d0 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 5 Nov 2026 00:04:42 +0800 Subject: [PATCH 40/53] fix(tools): disclose non-overlapping anchor counting in fim copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit std matches() counts non-overlapping occurrences, so a self-similar anchor ("aa" inside "aaa") passes the uniqueness check with count 1 and the edit lands at the first occurrence. Stated bare, "must appear exactly once" reads as overlapping occurrences to a model counting with overlapping intuition — the sibling edit tool already discloses its "unique, non-overlapping region" contract, so fim now says the same at both levels (tool description and both anchor schema descriptions) and a copy test pins all three sentences. Signed-off-by: asto18089 --- crates/tui/src/tools/fim.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tools/fim.rs b/crates/tui/src/tools/fim.rs index c4241bb905..791854d465 100644 --- a/crates/tui/src/tools/fim.rs +++ b/crates/tui/src/tools/fim.rs @@ -82,7 +82,8 @@ impl ToolSpec for FimEditTool { expose a fill-in-the-middle completions endpoint, and the call fails \ otherwise. Each anchor must appear exactly once — the prefix anywhere \ in the file, the suffix after the prefix — and ambiguous anchors are \ - refused with their match count." + refused with their match count (occurrences are counted without \ + overlap)." } fn input_schema(&self) -> Value { @@ -95,11 +96,11 @@ impl ToolSpec for FimEditTool { }, "prefix_anchor": { "type": "string", - "description": "Text anchor marking the end of the prefix. Everything up to and including this anchor is kept as-is before the generated middle. Must appear exactly once in the file." + "description": "Text anchor marking the end of the prefix. Everything up to and including this anchor is kept as-is before the generated middle. Must appear exactly once in the file (counted without overlap)." }, "suffix_anchor": { "type": "string", - "description": "Text anchor marking the start of the suffix. Everything from this anchor onward is kept as-is after the generated middle. Must appear exactly once after the prefix anchor." + "description": "Text anchor marking the start of the suffix. Everything from this anchor onward is kept as-is after the generated middle. Must appear exactly once after the prefix anchor (counted without overlap)." }, "max_tokens": { "type": "integer", @@ -356,4 +357,28 @@ mod tests { "{message}" ); } + + #[test] + fn fim_copy_pins_the_non_overlapping_counting_contract() { + // std matches() counts non-overlapping occurrences, so a + // self-similar anchor ("aa" in "aaa") passes with count 1. The + // copy must disclose that counting rule or "exactly once" reads + // as overlapping occurrences to the model. + let tool = FimEditTool::new(None, "fim-model".to_string()); + let description = tool.description(); + assert!(description.contains("exactly once"), "{description}"); + assert!( + description.contains("counted without overlap"), + "{description}" + ); + let schema = tool.input_schema(); + let prefix = schema["properties"]["prefix_anchor"]["description"] + .as_str() + .expect("prefix_anchor description"); + let suffix = schema["properties"]["suffix_anchor"]["description"] + .as_str() + .expect("suffix_anchor description"); + assert!(prefix.contains("counted without overlap"), "{prefix}"); + assert!(suffix.contains("counted without overlap"), "{suffix}"); + } } From 279b313c32195c4344e1285a1b02fc4c072fb3d5 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 5 Nov 2026 00:04:55 +0800 Subject: [PATCH 41/53] test(tools): pin the fim pass-through corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty anchor is not special-cased: the refusal emerges from the general matches-more-than-once rule. The two corners therefore pass with count 1 and keep the base behavior — an empty prefix on an empty file (single char boundary) and an empty suffix after a prefix ending at EOF (empty suffix region). Both now pin that they reach the API boundary instead of a refusal; the round-4 note in the PR body had claimed this pinning existed, but no such tests landed with that wave. Signed-off-by: asto18089 --- crates/tui/src/tools/fim.rs | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/tui/src/tools/fim.rs b/crates/tui/src/tools/fim.rs index 791854d465..629f25e13d 100644 --- a/crates/tui/src/tools/fim.rs +++ b/crates/tui/src/tools/fim.rs @@ -381,4 +381,47 @@ mod tests { assert!(prefix.contains("counted without overlap"), "{prefix}"); assert!(suffix.contains("counted without overlap"), "{suffix}"); } + + #[tokio::test] + async fn empty_prefix_anchor_on_an_empty_file_passes_to_the_fim_call() { + // A non-empty file refuses an empty prefix because it matches at + // every char boundary. On an empty file the single boundary + // yields count 1, so validation passes and the call reaches the + // API boundary — the base behavior, not a refusal. + let (_tmp, context) = workspace_with(""); + let message = execution_error( + serde_json::json!({ + "path": "sample.txt", + "prefix_anchor": "", + "suffix_anchor": "", + }), + &context, + ) + .await; + assert!( + message.contains("FIM API client not available"), + "empty prefix on an empty file must pass validation: {message}" + ); + } + + #[tokio::test] + async fn empty_suffix_after_an_eof_ending_prefix_passes_to_the_fim_call() { + // A prefix ending at EOF leaves an empty suffix region, where an + // empty suffix matches exactly once, so validation passes and the + // call reaches the API boundary — the base behavior, not a refusal. + let (_tmp, context) = workspace_with("tail anchor"); + let message = execution_error( + serde_json::json!({ + "path": "sample.txt", + "prefix_anchor": "tail anchor", + "suffix_anchor": "", + }), + &context, + ) + .await; + assert!( + message.contains("FIM API client not available"), + "empty suffix after an EOF-ending prefix must pass validation: {message}" + ); + } } From 3f07d01cbc4d3062678fa3b00ad7295053fae287 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 5 Nov 2026 00:05:06 +0800 Subject: [PATCH 42/53] test(tools): pin the read_media encoded-payload label The round-4 fix that relabeled the receipt's pre-base64 size from "(wire payload)" to "(encoded payload)" carried no test, so a silent revert would be invisible to CI. The delivery-note test now asserts the encoded-payload label is present and the wire-payload wording is gone. Signed-off-by: asto18089 --- crates/tui/src/tools/read_media.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tui/src/tools/read_media.rs b/crates/tui/src/tools/read_media.rs index 87644fea1a..1432dfffb0 100644 --- a/crates/tui/src/tools/read_media.rs +++ b/crates/tui/src/tools/read_media.rs @@ -1917,6 +1917,14 @@ mod tests { assert!(note.contains("Original dimensions: 3000x1500"), "{note}"); assert!(note.contains("downsampled to 2048x1024"), "{note}"); assert!(note.contains("fine detail may be lost"), "{note}"); + assert!( + note.contains("(encoded payload)"), + "size line must label the pre-base64 bytes as encoded payload: {note}" + ); + assert!( + !note.contains("(wire payload)"), + "the pre-base64 size must not be labeled as the wire payload: {note}" + ); assert!( note.contains( "call read_media again with the crop parameter (original-image pixel coordinates)" From 8b6d17e0f57381df5ef03ba71842e0ef375118f6 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 5 Nov 2026 00:05:06 +0800 Subject: [PATCH 43/53] fix(tools): scope the allow_shell honored claim to the mode "Passing true or false is always honored" was an absolute: under mode=plan the value is stored but shell stays disabled by the mode's own hard block (the mode property already discloses exactly that precedence). The allow_shell copy now says "honored subject to the mode", naming the plan carve-out, and the authority-copy pin test asserts both new sentences. Signed-off-by: asto18089 --- crates/tui/src/tools/tasks.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tools/tasks.rs b/crates/tui/src/tools/tasks.rs index 02ce9ca866..5af7331f96 100644 --- a/crates/tui/src/tools/tasks.rs +++ b/crates/tui/src/tools/tasks.rs @@ -195,7 +195,7 @@ impl ToolSpec for TasksTool { ); properties.insert( "allow_shell".to_string(), - json!({ "type": "boolean", "description": "Whether the task's session may run shell commands. Passing true or false is always honored; omitting falls back to the configured durable-task allow-shell setting, which is false unless shell execution is enabled in config. (action=create)" }), + json!({ "type": "boolean", "description": "Whether the task's session may run shell commands. Passing true or false is honored subject to the mode — plan keeps shell disabled even when this is true (see mode); omitting falls back to the configured durable-task allow-shell setting, which is false unless shell execution is enabled in config. (action=create)" }), ); properties.insert( "trust_mode".to_string(), @@ -1473,6 +1473,14 @@ mod tests { allow_shell.contains("which is false unless shell execution is enabled in config"), "{allow_shell}" ); + assert!( + allow_shell.contains("honored subject to the mode"), + "{allow_shell}" + ); + assert!( + allow_shell.contains("plan keeps shell disabled even when this is true"), + "{allow_shell}" + ); let trust_mode = schema["properties"]["trust_mode"]["description"] .as_str() .expect("trust_mode description"); From 71b61ea5d033881b58cb7f965a7b9be57d7ae233 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 5 Nov 2026 00:05:12 +0800 Subject: [PATCH 44/53] docs(prompts): drop the stale single-module tuning claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module header still ended on "Keeping every layer's text in one module makes prompt tuning a single-file operation." — contradicting the corrected constant-split header directly above it (authority recap and locale bookends live in this module) and surviving two header cleanup waves. The handoff test whose name still said "prepended" is renamed to what it actually pins (inclusion); the prepending doc claim it echoed was corrected in the round-4 wave. Signed-off-by: asto18089 --- crates/tui/src/prompts.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 6d385e97e8..5876712bf1 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -8,8 +8,8 @@ //! approval policy → request-time runtime metadata. //! Tool availability comes only from the per-turn model catalog. //! -//! Keeping every layer's text in one module makes prompt tuning a -//! single-file operation. +//! Every layer's text stays a compile-time constant — this module and +//! `text`, per the split above. use crate::models::{SystemBlock, SystemPrompt}; use crate::project_context::load_project_context_with_parents; @@ -3136,7 +3136,7 @@ mod tests { } #[test] - fn handoff_artifact_is_prepended_to_system_prompt_when_present() { + fn handoff_artifact_is_included_in_the_system_prompt_when_present() { let tmp = tempdir().expect("tempdir"); let workspace = tmp.path(); let handoff_dir = workspace.join(".deepseek"); From 4a36f4be608b5a17c5f13e7a808d69b6dfd34543 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 15:54:41 +0800 Subject: [PATCH 45/53] fix(tools): scope the read decode clause to the binary refusal The contract reader now refuses NUL-bearing payloads (any NUL in the first 8 KiB) and UTF-16 BOMs as binary before the lossy decode (contract_read_payload_is_binary, added to file.rs by the subagent-isolation work while this PR was in review), so the read description's 'bytes that are not valid UTF-8 become replacement characters instead of failing the read' was an absolute the engine no longer honors: stray high bytes and NUL-free heads still decode lossily, the refused classes do not. Scope the clause with the refusal exception, pin the disclosure in the description pin (and forbid the unscoped sentence end from coming back), and correct the refusal message's 'read returns text only' absolute - read returns inline images for sniffed formats - to 'read returns text or inline images, never raw binary', pinned in the behavioral refusal test. Signed-off-by: asto --- crates/tui/src/tools/file.rs | 2 +- crates/tui/src/tools/file/tests.rs | 1 + crates/tui/src/tools/file_tool.rs | 2 +- crates/tui/src/tools/file_tool/tests.rs | 12 +++++++++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index be0cdadeac..1c44930b8e 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -792,7 +792,7 @@ impl ReadFileTool { // delegate, so the message says so instead of naming unreachable commands. if contract_read_payload_is_binary(&bytes) { return Err(ToolError::execution_failed(format!( - "{path_str} is a binary file ({}); read returns text only. Identify it with `file` if your role has a shell; if it is a zip-based document (.pptx/.docx/.xlsx/.zip), list its members with `unzip -l` and read them with `unzip -p` under a role that permits unzip. If neither command is available to you, hand the file to a role that can extract it instead of re-reading.", + "{path_str} is a binary file ({}); read returns text or inline images, never raw binary. Identify it with `file` if your role has a shell; if it is a zip-based document (.pptx/.docx/.xlsx/.zip), list its members with `unzip -l` and read them with `unzip -p` under a role that permits unzip. If neither command is available to you, hand the file to a role that can extract it instead of re-reading.", contract_format_size(bytes.len()) ))); } diff --git a/crates/tui/src/tools/file/tests.rs b/crates/tui/src/tools/file/tests.rs index 763c576f81..f14b77d7e4 100644 --- a/crates/tui/src/tools/file/tests.rs +++ b/crates/tui/src/tools/file/tests.rs @@ -144,6 +144,7 @@ async fn contract_read_refuses_binary_payloads_but_keeps_lossy_text() { assert!( error.contains(name) && error.contains("binary file") + && error.contains("read returns text or inline images, never raw binary") && error.contains("Identify it with `file`") && error.contains("unzip") && error.contains("hand the file to a role that can extract it"), diff --git a/crates/tui/src/tools/file_tool.rs b/crates/tui/src/tools/file_tool.rs index 5b418cdc77..2cc13f8fdc 100644 --- a/crates/tui/src/tools/file_tool.rs +++ b/crates/tui/src/tools/file_tool.rs @@ -60,7 +60,7 @@ impl ToolSpec for ReadTool { } fn description(&self) -> &'static str { - "Read a file. PNG, JPEG, GIF, and WebP files up to 5 MiB are returned as images for visual inspection; BMP files and images above 5 MiB are detected but replaced with an omission note. Anything else is decoded as UTF-8 text; bytes that are not valid UTF-8 become replacement characters instead of failing the read. By default, text output is limited to 2000 complete lines or 50KB, whichever comes first. Use offset and limit to continue through large files." + "Read a file. PNG, JPEG, GIF, and WebP files up to 5 MiB are returned as images for visual inspection; BMP files and images above 5 MiB are detected but replaced with an omission note. Anything else is decoded as UTF-8 text; bytes that are not valid UTF-8 become replacement characters instead of failing the read, except that a payload carrying a NUL byte in its first 8 KiB, or beginning with a UTF-16 byte-order mark, is refused as binary. By default, text output is limited to 2000 complete lines or 50KB, whichever comes first. Use offset and limit to continue through large files." } fn input_schema(&self) -> Value { diff --git a/crates/tui/src/tools/file_tool/tests.rs b/crates/tui/src/tools/file_tool/tests.rs index b0391e794a..e894f0526b 100644 --- a/crates/tui/src/tools/file_tool/tests.rs +++ b/crates/tui/src/tools/file_tool/tests.rs @@ -24,7 +24,9 @@ fn read_description_pins_the_disclosed_boundaries() { "{description}" ); assert!( - description.contains("bytes that are not valid UTF-8 become replacement characters"), + description.contains( + "bytes that are not valid UTF-8 become replacement characters instead of failing the read, except that a payload carrying a NUL byte in its first 8 KiB, or beginning with a UTF-16 byte-order mark, is refused as binary" + ), "{description}" ); assert!( @@ -35,6 +37,14 @@ fn read_description_pins_the_disclosed_boundaries() { !description.contains("Read a text file"), "the read tool must not claim to be text-only: {description}" ); + // The lossy-decode clause must stay scoped by the binary refusal (the + // contract reader refuses NUL-bearing heads and UTF-16 BOMs before + // decoding): an unscoped "instead of failing the read" sentence end is + // the pre-refusal absolute and must not come back. + assert!( + !description.contains("instead of failing the read."), + "the lossy-decode claim must keep the binary-refusal exception: {description}" + ); } #[tokio::test] From bcf944c593c6ef415590d0593e666d9708f413f9 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 15:55:31 +0800 Subject: [PATCH 46/53] test(tools): pin the harness rlm removal The wave-5 harness fix (stop teaching the hidden rlm compatibility surface; name the visible bounded-reread surfaces instead) landed with copy only - no test guarded it, so re-adding both original rlm mentions passes every guard and suite (mutation-proven in review). Pin the removal in both model-visible strings: the description and the overview result's runtime.context must carry no rlm and must keep naming read with offset/limit and retrieve_tool_result. Signed-off-by: asto --- crates/tui/src/tools/harness.rs | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/tui/src/tools/harness.rs b/crates/tui/src/tools/harness.rs index 6c92b4b657..976c0b9788 100644 --- a/crates/tui/src/tools/harness.rs +++ b/crates/tui/src/tools/harness.rs @@ -201,6 +201,44 @@ mod tests { assert!(overview.content.contains("workflow composes")); } + #[tokio::test] + async fn harness_copy_names_only_the_visible_bounded_reread_surfaces() { + // The copy used to send large material to `rlm`, a hidden + // compatibility tool (`model_visible() == false`) whose own + // description says not to teach it to new model turns; + // allowlist-deriving hosts reject the very call it recommended. Both + // model-visible strings must keep naming the visible bounded-reread + // surfaces instead, and the phantom name must not return. + let description = HarnessTool.description(); + assert!( + !description.contains("rlm"), + "harness description must not teach the hidden rlm surface: {description}" + ); + assert!( + description.contains("read with offset/limit") + && description.contains("retrieve_tool_result"), + "the description must name the visible bounded-reread surfaces: {description}" + ); + + let tmp = tempdir().expect("tempdir"); + let context = ToolContext::new(tmp.path()); + let overview = HarnessTool + .execute(json!({"action": "overview"}), &context) + .await + .expect("overview result"); + assert!( + !overview.content.contains("rlm"), + "overview runtime context must not name rlm: {}", + overview.content + ); + assert!( + overview.content.contains("read with offset/limit") + && overview.content.contains("retrieve_tool_result"), + "{}", + overview.content + ); + } + #[tokio::test] async fn refine_rejects_missing_evidence() { let tmp = tempdir().expect("tempdir"); From 5fbf16822a560b567a103d0aa1f5724a050081ad Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 15:55:59 +0800 Subject: [PATCH 47/53] docs(prompts): scope the consolidation claim and fix the test command Two header residuals the wave cleanup missed: the consolidation sentence still claimed in the present tense that the whole prompt contract reads top-to-bottom in this one module, contradicting the corrected header directly above it - the authority recap and the locale bookends never lived here, so the claim is now scoped to the constants that moved. And the edit-loop command ran 'cargo test -p codewhale-tui --bin codewhale-tui prompts', which matches zero tests (the prompts tests live in the lib target); it now names --lib. Signed-off-by: asto --- crates/tui/src/prompts/text.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/prompts/text.rs b/crates/tui/src/prompts/text.rs index 044140c164..835128f228 100644 --- a/crates/tui/src/prompts/text.rs +++ b/crates/tui/src/prompts/text.rs @@ -4,10 +4,11 @@ //! //! Each constant below used to live in its own `prompts/*.md` file, pulled in //! with `include_str!`. The per-layer file sprawl (17 files across 4 -//! directories) was consolidated into this one module so the whole prompt -//! contract reads top-to-bottom in a single place, the way the runtime -//! assembly composes it. The text moved **verbatim** — every constant is -//! byte-identical to the file it replaced, trailing newline included — so +//! directories) was consolidated into this module for the constants that +//! moved; the authority recap and the locale bookends have always lived in +//! `prompts.rs`, so the bulk — not the whole — of the prompt contract reads +//! top-to-bottom in one place. The text moved **verbatim** — every constant +//! is byte-identical to the file it replaced, trailing newline included — so //! rendered prompts do not change by a single byte. //! //! Organization follows the runtime assembly order, most-static → @@ -30,7 +31,7 @@ //! Edit prompt text here directly. Content and ordering invariants are //! guarded by the test suite in `../prompts.rs` (constitution structure, //! binding gates, prefix privacy, byte-stable prefix ordering) — run -//! `cargo test -p codewhale-tui --bin codewhale-tui prompts` after edits. +//! `cargo test -p codewhale-tui --lib prompts` after edits. //! //! The locale-tagged bookends (per-locale preambles/closers) remain in //! `../prompts.rs` next to the override cells that can replace them. From a326ba9ce6973e6c645111a973f741a6692d977c Mon Sep 17 00:00:00 2001 From: asto18089 Date: Fri, 6 Nov 2026 13:31:48 +0800 Subject: [PATCH 48/53] fix(tools): scope the auto-approve copy to armed guardrails Base #131 changed the approval pump to auto_approve && !approval_force_prompt, so on a host that arms ask-on-guardrail guardrails, guardrail-class calls force a real human approval even for an auto_approve=true task. The "no approval prompts" absolute (and the mode row skip parenthetical) now carry that exception, the mode row discloses that trust_mode's effects still apply in plan (the retry path has no mode re-check), and the pins assert the exception clauses and forbid the unscoped absolute from returning. Signed-off-by: asto18089 --- crates/tui/src/tools/tasks.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/tasks.rs b/crates/tui/src/tools/tasks.rs index 5af7331f96..80197a2904 100644 --- a/crates/tui/src/tools/tasks.rs +++ b/crates/tui/src/tools/tasks.rs @@ -191,7 +191,7 @@ impl ToolSpec for TasksTool { ); properties.insert( "mode".to_string(), - json!({ "type": "string", "enum": ["agent", "plan", "operate"], "description": "Operating mode for the task's session. 'plan' runs read-only: shell is disabled even if allow_shell is true, and plan's read-only blocks are not lifted by auto_approve (which otherwise skips approval prompts). 'agent' (default) and 'operate' run with the task's allow_shell/trust_mode/auto_approve values. (action=create)" }), + json!({ "type": "string", "enum": ["agent", "plan", "operate"], "description": "Operating mode for the task's session. 'plan' runs read-only: shell is disabled even if allow_shell is true, and plan's read-only blocks are not lifted by auto_approve (which otherwise skips approval prompts; armed ask-on-guardrail guardrails still prompt). trust_mode's effects still apply in plan. 'agent' (default) and 'operate' run with the task's allow_shell/trust_mode/auto_approve values. (action=create)" }), ); properties.insert( "allow_shell".to_string(), @@ -203,7 +203,7 @@ impl ToolSpec for TasksTool { ); properties.insert( "auto_approve".to_string(), - json!({ "type": "boolean", "description": "Run the task with tool calls auto-approved (full-access posture, no approval prompts). Defaults to false. (action=create)" }), + json!({ "type": "boolean", "description": "Run the task with tool calls auto-approved (full-access posture, no approval prompts — armed ask-on-guardrail guardrails still force a human approval for the calls they guard). Defaults to false. (action=create)" }), ); properties.insert( "gate".to_string(), @@ -1461,6 +1461,14 @@ mod tests { mode.contains("plan's read-only blocks are not lifted by auto_approve"), "{mode}" ); + assert!( + mode.contains("armed ask-on-guardrail guardrails still prompt"), + "the approval-skip parenthetical must carry the armed-guardrail exception: {mode}" + ); + assert!( + mode.contains("trust_mode's effects still apply in plan"), + "the plan read-only lead-in must disclose trust_mode's in-plan effects: {mode}" + ); let allow_shell = schema["properties"]["allow_shell"]["description"] .as_str() .expect("allow_shell description"); @@ -1492,6 +1500,16 @@ mod tests { .as_str() .expect("auto_approve description"); assert!(auto_approve.contains("Defaults to false"), "{auto_approve}"); + assert!( + auto_approve.contains( + "no approval prompts — armed ask-on-guardrail guardrails still force a human approval", + ), + "the auto-approve absolute must carry the armed-guardrail exception: {auto_approve}" + ); + assert!( + !auto_approve.contains("no approval prompts)."), + "the unscoped no-prompts absolute must not return: {auto_approve}" + ); let command = schema["properties"]["command"]["description"] .as_str() .expect("command description"); From 4f00c64fe96cd0c7f71508b93e3802a913cb1124 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Fri, 6 Nov 2026 13:34:24 +0800 Subject: [PATCH 49/53] fix(tools): scope the tasks shell-tool pointer and task-id default copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tasks description pointed at task_shell_start unconditionally, but the registry registers the runtime task shell tools only under ShellPolicy::Full (allow_shell on, non-plan mode) — on the default durable-task surface the named tool is absent from the catalog. The task_id property grouped read with cancel as requiring an id, while read resolves through the same active-task default as pr_attempt_*. Both sentences are scoped and pinned. Signed-off-by: asto18089 --- crates/tui/src/tools/tasks.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/tasks.rs b/crates/tui/src/tools/tasks.rs index 80197a2904..a81c47f061 100644 --- a/crates/tui/src/tools/tasks.rs +++ b/crates/tui/src/tools/tasks.rs @@ -153,7 +153,7 @@ impl ToolSpec for TasksTool { if self.read_only { "Inspect durable tasks and their PR attempts. Actions: \"list\", \"read\", \"pr_attempt_list\", \"pr_attempt_read\"." } else { - "Manage durable background tasks through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents. Actions: \"create\" (enqueue; approval), \"list\", \"read\", \"cancel\" (approval), \"gate_run\" (run an approved verification gate command and return structured evidence; approval), \"pr_attempt_record\" (approval), \"pr_attempt_list\", \"pr_attempt_read\", \"pr_attempt_preflight\" (approval). Use task_shell_start for long-running shell work." + "Manage durable background tasks through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents. Actions: \"create\" (enqueue; approval), \"list\", \"read\", \"cancel\" (approval), \"gate_run\" (run an approved verification gate command and return structured evidence; approval), \"pr_attempt_record\" (approval), \"pr_attempt_list\", \"pr_attempt_read\", \"pr_attempt_preflight\" (approval). When shell execution is enabled for the task (allow_shell on, non-plan mode), use task_shell_start for long-running shell work." } } @@ -252,7 +252,7 @@ impl ToolSpec for TasksTool { ); properties.insert( "task_id".to_string(), - json!({ "type": "string", "description": "Full task id or unambiguous prefix (action=read/cancel); task id, defaults to active task (action=pr_attempt_*)." }), + json!({ "type": "string", "description": "Full task id or unambiguous prefix (action=cancel); omitted, it defaults to the active task (action=read/pr_attempt_*)." }), ); properties.insert( "limit".to_string(), @@ -1510,6 +1510,29 @@ mod tests { !auto_approve.contains("no approval prompts)."), "the unscoped no-prompts absolute must not return: {auto_approve}" ); + let tool = TasksTool::new("tasks"); + let description = tool.description(); + assert!( + description.contains( + "When shell execution is enabled for the task (allow_shell on, non-plan mode), use task_shell_start", + ), + "the shell-tool pointer must be scoped to surfaces that register it: {description}" + ); + assert!( + !description.contains("Use task_shell_start for long-running shell work."), + "the unconditional shell-tool pointer must not return: {description}" + ); + let task_id = schema["properties"]["task_id"]["description"] + .as_str() + .expect("task_id description"); + assert!( + task_id.contains("defaults to the active task (action=read/pr_attempt_*)"), + "read and pr_attempt_* share the active-task default: {task_id}" + ); + assert!( + !task_id.contains("(action=read/cancel)"), + "read must not be listed as requiring a task id: {task_id}" + ); let command = schema["properties"]["command"]["description"] .as_str() .expect("command description"); From 7936c7eff19aec92cabd2263d9ad22019e469d18 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Fri, 6 Nov 2026 13:56:29 +0800 Subject: [PATCH 50/53] test(tools): pin the read_media delivery-budget copy The item-5b budget sentences on the description and the detail schema were the one audited copy surface with no revert guard; a mutation probe flipping the figure passed the whole suite. Both surfaces are now pinned. Signed-off-by: asto18089 --- crates/tui/src/tools/read_media.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/tui/src/tools/read_media.rs b/crates/tui/src/tools/read_media.rs index 1432dfffb0..75ce46829b 100644 --- a/crates/tui/src/tools/read_media.rs +++ b/crates/tui/src/tools/read_media.rs @@ -1933,6 +1933,35 @@ mod tests { ); } + #[test] + fn read_media_copy_pins_the_delivery_budget_sentences() { + // The budget disclosure is the direct item-5b fix; pin both + // surfaces so a silent revert (or a silently widened figure) + // fails the suite. + let tool = ReadMediaTool; + let description = tool.description(); + assert!( + description.contains("fit a ~256KB delivery budget"), + "the auto/low delivery budget must stay disclosed: {description}" + ); + assert!( + description.contains("keep the full 5 MiB payload limit"), + "the high/original/crop wire budget must stay disclosed: {description}" + ); + let schema = tool.input_schema(); + let detail = schema["properties"]["detail"]["description"] + .as_str() + .expect("detail description"); + assert!( + detail.contains("same ~256KB delivery budget as 'low'"), + "detail must tie auto to low's budget: {detail}" + ); + assert!( + detail.contains("full 5 MiB payload limit"), + "detail must keep the high/original wire budget: {detail}" + ); + } + #[tokio::test] async fn read_media_crop_after_downsample_pulls_full_res_stored_original() { let dir = tempdir().unwrap(); From 8374e64c87b72c0eba5d9c67ee6ff713a918c439 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Fri, 6 Nov 2026 14:01:02 +0800 Subject: [PATCH 51/53] test(tools): pin the fim non-overlapping counting behavior The schema copy discloses that anchor occurrences are counted without overlap, but no test exercised a self-overlapping anchor, so an overlapping-counting refactor would have kept every test green while making the live copy false. "aa" in "aaa" has exactly one non-overlapping site and reaches the API boundary; an overlapping counter refuses. Signed-off-by: asto18089 --- crates/tui/src/tools/fim.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/tui/src/tools/fim.rs b/crates/tui/src/tools/fim.rs index 629f25e13d..b338f94b9a 100644 --- a/crates/tui/src/tools/fim.rs +++ b/crates/tui/src/tools/fim.rs @@ -424,4 +424,26 @@ mod tests { "empty suffix after an EOF-ending prefix must pass validation: {message}" ); } + + #[tokio::test] + async fn self_overlapping_prefix_is_counted_without_overlap() { + // "aa" overlaps itself inside "aaa": an overlapping counter would + // report 2 locations and refuse, but occurrences are counted + // without overlap — the rule the schema copy discloses — so the + // single site passes and the call reaches the API boundary. + let (_tmp, context) = workspace_with("aaa\nbbb\n"); + let message = execution_error( + serde_json::json!({ + "path": "sample.txt", + "prefix_anchor": "aa", + "suffix_anchor": "b\n", + }), + &context, + ) + .await; + assert!( + message.contains("FIM API client not available"), + "a self-overlapping prefix with one non-overlapping site must pass validation: {message}" + ); + } } From 64202f1d9a6e6611e3e822a2a17617bc684c3971 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Fri, 6 Nov 2026 14:08:05 +0800 Subject: [PATCH 52/53] test(tools): pin the shell working-dir advice on both arms The requested-directory arm (the model-reachable one) only forbade the literal "resume/fork" token on the saved-workspace path, so a paraphrased revival of the false advice there passed CI; the pin now forbids resume and fork wording on that arm outright. The saved-workspace not-a-directory branch (shell.rs Restore-it advice) had no test at all and gets one. Signed-off-by: asto18089 --- crates/tui/src/tools/shell/tests.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index a7ac5cd6c6..7a26942d8d 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -633,6 +633,33 @@ fn explicit_non_directory_working_dir_gets_a_direct_fix_hint() { assert!(message.contains("requested working directory is not a directory")); assert!(message.contains("Use a directory that exists")); assert!(!message.contains("saved session workspace")); + // The requested-directory arm is the one model-reachable advice + // surface; resume re-anchors to the same directory and a fork copies + // it, so neither may be suggested here in any wording. + assert!( + !message.contains("resume"), + "requested-directory arm must not advise resume: {message}" + ); + assert!( + !message.contains("fork"), + "requested-directory arm must not advise fork: {message}" + ); +} + +#[test] +fn saved_workspace_that_is_a_file_gets_the_saved_workspace_advice() { + let parent = tempdir().expect("parent"); + let file_workspace = parent.path().join("workspace-file"); + std::fs::write(&file_workspace, b"occupant").expect("write occupant file"); + let mut manager = ShellManager::new(file_workspace.clone()); + + let error = execute_shell(&mut manager, "echo should-not-run", None, 1_000, false) + .expect_err("saved workspace that is not a directory must fail before shell spawn"); + let message = error.to_string(); + assert!(message.contains("saved session workspace is not a directory")); + assert!(message.contains("Restore it as a directory")); + assert!(message.contains("/workspace")); + assert!(message.contains("/fork")); } #[test] From eab45d4aa8fe8ed4a574e52fb5af2310e7adef33 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Fri, 6 Nov 2026 14:14:29 +0800 Subject: [PATCH 53/53] docs(prompts): align the constant-doc claims with the assembled prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BASE_PROMPT's doc summary listed sections the constant does not have (output format, toolbox reference, "When NOT to use", sub-agent sentinel protocol live elsewhere), and the AUTHORITY_RECAP doc claimed it is appended at the end of the system prompt when the locale closer follows it for non-English locales — it is a per-request trailer after the WorldState fragments. Signed-off-by: asto18089 --- crates/tui/src/prompts.rs | 8 ++++---- crates/tui/src/prompts/text.rs | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 5876712bf1..1d2e92cb73 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -1013,10 +1013,10 @@ fn apply_model_template( prompt.replace("{model_id}", model_id) } -/// Authority recap block — appended at the end of the system prompt, -/// just before the user's first message. Uses recency bias constructively -/// without restating ranks: `BASE_PROMPT` § Whose word wins states the -/// default ranking (#4777). +/// Authority recap block — appended as a per-request trailer after the +/// WorldState fragments, just before the locale closer. Uses recency bias +/// constructively without restating ranks: `BASE_PROMPT` § Whose word wins +/// states the default ranking (#4777). const AUTHORITY_RECAP: &str = "\ ## Authority Recap diff --git a/crates/tui/src/prompts/text.rs b/crates/tui/src/prompts/text.rs index 835128f228..1f1f125b6a 100644 --- a/crates/tui/src/prompts/text.rs +++ b/crates/tui/src/prompts/text.rs @@ -37,8 +37,9 @@ //! `../prompts.rs` next to the override cells that can replace them. // ── Constitution — the binding core (#4032) ───────────────────────── -/// Core: task execution, tool-use rules, output format, toolbox reference, -/// "When NOT to use" guidance, sub-agent sentinel protocol. +/// Core: ground truth, user intent and scope, truthful completion, +/// guarantees-in-mechanism, and the default authority ranking +/// ("Whose word wins"). /// /// This text is the single hand-maintained source of the constitutional /// system prompt. The earlier YAML + Python-renderer generation pipeline