Skip to main content

speccoder_runtime/substrate/
attempt.rs

1//! `speccoder.task.attempt`: one bounded, governed attempt at a task
2//!.
3//!
4//! The model works through speccoder's tools; every call it asks for is
5//! classified by the policy first (deny → refused and told; approval → the
6//! attempt stops for a person; allow → dispatched through agentplane). The
7//! model's closing report is recorded as its claim — the verdict is computed
8//! afterwards by the verifier, never here.
9
10use crate::format::Wording;
11use agentplane::core::Label;
12use agentplane::model::{ToolDeclaration, Transcript};
13use agentplane::prelude::*;
14use agentplane::tools::ToolId;
15use serde_json::{Value, json};
16
17use super::model_config::{CacheTtl, PromptStyle};
18use super::propose::layout;
19use super::provenance;
20use crate::approvals::{Answer, Decision as Said, PresentContext, Request, Scope, Who};
21use crate::tools::policy::{Decision, Object, Policy};
22
23/// The capability the skill provides.
24pub const CAPABILITY: &str = "speccoder.task.attempt";
25
26/// The control tool for an honest exit. Handled by the skill, not
27/// dispatched anywhere.
28pub const REPORT_BLOCKED: &str = "report_blocked";
29
30/// What speccoder tells the model about an attempt; stable for the project.
31pub fn policy(w: &Wording) -> String {
32    format!(
33        "You are speccoder, a coding agent implementing exactly one {product} task in a git \
34worktree. Use the tools to inspect the code, make the smallest change that completes the \
35task, and run the project's checks with run_command to see whether it works. Stay inside \
36the task's scope: the task's paths are the plan's guess — where they differ from the code, \
37use the module that holds it; a change elsewhere that the task or its acceptance tests need \
38(an entry point they run, a module they import) is in scope and shown to the reviewer, never \
39a reason to stop: an entry point that bypasses the task's module is rewired to it. You \
40cannot edit the spec ({intent}), the checks' configuration or .git; such calls are refused. Some commands need a person's approval and will end the \
41attempt — prefer configured checks. If the task cannot be done as written — it \
42contradicts the spec, needs information or access you lack, or a test is wrong — call \
43report_blocked with the reason instead of working around it. Test files that existed \
44when the attempt started are frozen: add new tests in new files (or next to the code), \
45never by editing those. Never weaken, skip or delete tests to make checks pass. Never \
46return values copied from tests or examples in place of the specified behaviour (a \
47constant where the spec asks for a computed, fetched or current value is a cheat): if \
48the specified behaviour is impossible under the stated constraints, call report_blocked. \
49You are not the judge: when you finish, speccoder runs the checks itself and decides. \
50Finish with the requested structured report; its status is `incomplete` only when part of \
51the task is left undone — a check you could not run is no reason.",
52        product = w.product,
53        intent = w.intent_paths,
54    )
55}
56
57/// The final report's schema (the model's claim).
58#[must_use]
59pub fn report_schema() -> Value {
60    let strings = json!({ "type": "array", "items": { "type": "string" } });
61    json!({
62        "type": "object",
63        "properties": {
64            "summary": { "type": "string" },
65            "status": { "type": "string", "enum": ["done", "incomplete"] },
66            "files_changed": strings,
67            "checks_run": strings,
68            "notes": strings,
69        },
70        "required": ["summary", "status", "files_changed", "checks_run", "notes"],
71        "additionalProperties": false,
72    })
73}
74
75fn report_blocked() -> ToolDeclaration {
76    ToolDeclaration::new(
77        REPORT_BLOCKED,
78        "Stop the attempt because the task cannot be done as written; a person will look at the \
79reason. `kind`, with the `evidence` speccoder checks: `refused` — a call the task needs was \
80refused (the call); `spec_conflict` — the spec contradicts the task (its exact words); \
81`wrong_test` — a frozen test asserts the wrong thing (its path); `needs_information` — only a \
82person knows (the question). Scope is never a kind: when the tests run code that bypasses the \
83task's module (an entry point, a caller), wiring that code to it is part of the task.",
84        json!({
85            "type": "object",
86            "properties": {
87                "kind": { "type": "string", "enum": ["refused", "spec_conflict", "wrong_test", "needs_information"] },
88                "evidence": { "type": "string" },
89                "reason": { "type": "string" },
90            },
91            "required": ["kind", "evidence", "reason"],
92            "additionalProperties": false,
93        }),
94    )
95}
96
97/// What a `report_blocked` call is checked against: its evidence must be
98/// there, never the model's word alone.
99#[derive(Default)]
100pub(crate) struct StopGate {
101    /// The spec text the prompt carried: what a `spec_conflict` quotes.
102    pub spec: String,
103    /// The tests frozen when the attempt started: what a `wrong_test` names.
104    pub frozen: Vec<String>,
105    /// A person rejected a call for this task before.
106    pub rejected: bool,
107}
108
109/// What speccoder does with a `report_blocked` call.
110#[derive(Debug, PartialEq, Eq)]
111enum Stop {
112    /// The evidence is not there: the attempt goes on, told why.
113    Refused(String),
114    /// Asked once before stopping, with the turns left.
115    Asked(String),
116    /// The attempt stops; the reason the person reads.
117    Stopped(String),
118}
119
120/// Judges a stop by its evidence: a refused call stops at once; a quote,
121/// a frozen test or a question is asked about once first.
122fn judge_stop(
123    gate: &StopGate,
124    arguments: &Value,
125    refused: bool,
126    asked: bool,
127    turns_left: u32,
128) -> Stop {
129    let text = |k: &str| arguments[k].as_str().unwrap_or_default().trim();
130    let (kind, evidence, reason) = (text("kind"), text("evidence"), text("reason"));
131    let words = |t: &str| t.split_whitespace().collect::<Vec<_>>().join(" ");
132    let checked = match kind {
133        "refused" if refused => format!("refused call `{evidence}`"),
134        "refused" => return Stop::Refused("not stopped: no call in this attempt was refused or rejected".to_owned()),
135        "spec_conflict" if evidence.chars().count() >= 12 && words(&gate.spec).contains(&words(evidence)) => {
136            format!("the spec says “{evidence}”")
137        }
138        "spec_conflict" => {
139            return Stop::Refused("not stopped: the evidence must be the spec's exact words, at least a phrase; these are not in it".to_owned());
140        }
141        "wrong_test" if gate.frozen.iter().any(|f| f == evidence.trim_start_matches("./")) => {
142            format!("frozen test `{evidence}`")
143        }
144        "wrong_test" => return Stop::Refused("not stopped: the evidence must be the path of a test that existed when the attempt started".to_owned()),
145        "needs_information" if !evidence.is_empty() => format!("question: {evidence}"),
146        _ => return Stop::Refused("not stopped: `kind` is one of refused, spec_conflict, wrong_test, needs_information, with its `evidence`".to_owned()),
147    };
148    // The model's reason first, then what speccoder checked.
149    let said = match reason {
150        "" => checked,
151        r if r == evidence => r.to_owned(),
152        r => format!("{r} — {checked}"),
153    };
154    // Asked once only, and never on the last turn: no answer could follow.
155    if kind == "refused" || asked || turns_left == 0 {
156        return Stop::Stopped(said);
157    }
158    Stop::Asked(format!(
159        "Not stopped yet: speccoder asks once before a stop. {turns_left} turns are left. The task's paths are the plan's guess: wiring an entry point, caller or import the tests run to the task's module is part of the task, and a failing check is a reason to keep going. If the task still cannot be done as written, call report_blocked again."
160    ))
161}
162
163/// The question tool a hosted skill may use.
164pub const ASK_PERSON: &str = "ask_person";
165
166/// The `ask_person` declaration.
167#[must_use]
168pub fn ask_person() -> ToolDeclaration {
169    ToolDeclaration::new(
170        ASK_PERSON,
171        "Ask the person one question and wait for their answer. If nobody can answer, the reply says so.",
172        json!({
173            "type": "object",
174            "properties": { "question": { "type": "string" } },
175            "required": ["question"],
176            "additionalProperties": false,
177        }),
178    )
179}
180
181/// How an attempt waits for a person when a call needs one: on an
182/// agentplane human task the person decides — inline, from an editor, or
183/// later with `speccoder task approve` — while the run is suspended.
184#[derive(Debug, Clone)]
185pub struct Asker {
186    /// `feature/task`, shown to the person.
187    pub task: String,
188    /// The task's title.
189    pub task_title: String,
190    /// What the live run knows for the presentation.
191    pub context: PresentContext,
192    /// How long a call waits before it is denied as expired.
193    pub wait_secs: u64,
194}
195
196impl Asker {
197    /// The asker the journaled settings describe: `asks = "tasks"` waits on
198    /// a task; anything else (`"stop"`, a lane) stops the attempt instead.
199    #[must_use]
200    pub fn from_settings(
201        settings: &Value,
202        task: String,
203        task_title: String,
204        context: PresentContext,
205    ) -> Option<Self> {
206        (settings["asks"] == "tasks").then(|| Self {
207            task,
208            task_title,
209            context,
210            wait_secs: settings["wait_secs"].as_u64().unwrap_or(7 * 86_400),
211        })
212    }
213}
214
215/// Waits for a person's decision on `spec`, registering the `approval`
216/// obligation the first time: the run suspends until someone decides or the
217/// deadline passes. Journaled — replay reads the decision back.
218async fn wait_for_person(
219    cx: &mut StepCtx<'_>,
220    asker: &Asker,
221    obligation: &mut bool,
222    spec: &agentplane::core::TaskSpec,
223) -> Result<agentplane::core::Decision, SkillError> {
224    if !*obligation {
225        let minutes = u32::try_from(asker.wait_secs.div_ceil(60).max(1)).unwrap_or(u32::MAX);
226        cx.deadline(
227            crate::approvals::tasks::DEADLINE,
228            &agentplane::core::DeadlineSpec::minutes(minutes),
229            None,
230        )
231        .await?;
232        *obligation = true;
233    }
234    Ok(cx.task(spec).await?)
235}
236
237/// The coded skill.
238///
239/// Everything its decisions depend on — the bundle, the policy's command
240/// prefixes and the limits — arrives in the run's input, so it is journaled
241/// and strict replay needs no configuration. Only the prompt dialect
242/// is construction-time, and it is part of each model call's effect key.
243#[derive(Debug)]
244pub struct Attempt {
245    /// Prompt dialect.
246    pub style: PromptStyle,
247    /// Cache breakpoint lifetime.
248    pub cache_ttl: CacheTtl,
249    /// Where live progress goes; not journaled, `None` in replay.
250    pub progress: Option<std::sync::Arc<dyn crate::progress::Progress>>,
251    /// The project's spec format.
252    pub format: &'static dyn crate::format::Adapter,
253    /// The sandbox, as a person asked about a command is told; live only.
254    pub present: PresentContext,
255}
256
257/// The run input: `{ bundle, settings: { checks, allow, max_turns,
258/// max_tool_calls, frozen, … }, digest }` — `digest` is the verifier's
259/// account of the previous attempt, `null` on a first attempt.
260///
261/// Labelled by where each part came from: the bundle is the
262/// person's committed spec and plan (trusted); the digest quotes check
263/// output (`speccoder://verifier`); the settings list MCP tool descriptions
264/// (their servers).
265#[must_use]
266pub fn input(
267    bundle: Value,
268    settings: &Settings<'_>,
269    digest: Option<&Value>,
270    instructions: &[crate::instructions::Instruction],
271    test_layout: Option<&str>,
272) -> Tainted<Value> {
273    let digest = match digest.filter(|d| !d.is_null()) {
274        Some(d) => Tainted::with_label(d.clone(), provenance::from(provenance::VERIFIER)),
275        None => Tainted::trusted(Value::Null),
276    };
277    // Each MCP tool's description and schema are its server's words.
278    let mcp = Tainted::array(settings.mcp.iter().map(|t| {
279        Tainted::with_label(
280            serde_json::to_value(t).unwrap_or(Value::Null),
281            agentplane::core::Label::untrusted(provenance::tool(&t.server, &t.tool)),
282        )
283    }));
284    let trusted = |v: Value| Tainted::trusted(v);
285    let autopilot = settings.autopilot;
286    let planning = settings.planning;
287    let settings = vec![
288        ("checks", trusted(json!(settings.checks))),
289        ("allow", trusted(json!(settings.allow))),
290        ("max_turns", trusted(json!(settings.max_turns))),
291        ("max_tool_calls", trusted(json!(settings.max_tool_calls))),
292        ("reasoning", trusted(json!(settings.reasoning))),
293        ("frozen", trusted(json!(settings.frozen))),
294        ("approved", trusted(json!(settings.approved))),
295        ("rejected", trusted(json!(settings.rejected))),
296        ("run_approved", trusted(json!(settings.run_approved))),
297        ("approved_once", trusted(json!(settings.approved_once))),
298        ("nudges", trusted(json!(settings.nudges))),
299        ("mcp", mcp),
300        ("root", trusted(json!(settings.root))),
301        (
302            "frozen_additions",
303            trusted(json!(settings.frozen_additions)),
304        ),
305        ("network_allow", trusted(json!(settings.network_allow))),
306        ("allow_prefix", trusted(json!(settings.allow_prefix))),
307        (
308            "network_allow_prefix",
309            trusted(json!(settings.network_allow_prefix)),
310        ),
311        ("run_id", trusted(json!(settings.run_id))),
312        ("asked", trusted(json!(settings.asked))),
313        ("budget", trusted(json!(settings.budget))),
314        ("prompts", trusted(json!(settings.prompts))),
315        ("asks", trusted(json!(settings.asks))),
316        ("wait_secs", trusted(json!(settings.wait_secs))),
317        ("in_place", trusted(json!(settings.in_place))),
318        ("hidden", trusted(json!(settings.hidden))),
319    ];
320    // Only an autopilot run carries the key: every other input reads as
321    // it was recorded before it existed.
322    let mut settings = settings;
323    if autopilot {
324        settings.push(("autopilot", trusted(json!(true))));
325    }
326    // Likewise only a planning task's.
327    if let Some(dir) = planning {
328        settings.push(("planning", trusted(json!(dir))));
329    }
330    let settings = Tainted::object(settings);
331    Tainted::object([
332        ("bundle", Tainted::trusted(bundle)),
333        ("digest", digest),
334        ("settings", settings),
335        // The repository's text, as if the model had read the files itself;
336        // none is no text, and no label.
337        (
338            "instructions",
339            if instructions.is_empty() {
340                Tainted::trusted(json!([]))
341            } else {
342                Tainted::with_label(json!(instructions), provenance::from(provenance::WORKTREE))
343            },
344        ),
345        // The test layout speccoder detected from the checkout.
346        (
347            "test_layout",
348            match test_layout {
349                Some(t) => Tainted::with_label(json!(t), provenance::from(provenance::WORKTREE)),
350                None => Tainted::trusted(Value::Null),
351            },
352        ),
353    ])
354}
355
356/// A refusal speccoder composes about a call quotes it — the tool, path or
357/// command the model chose — so it carries the call's label; only
358/// constant replies are trusted.
359fn refusal(text: String, call: &agentplane::core::Label) -> Tainted<String> {
360    Tainted::with_label(text, call.clone())
361}
362
363/// The parts of an attempt's input its messages quote.
364const MESSAGE_PARTS: [&str; 7] = [
365    "/bundle",
366    "/instructions",
367    "/test_layout",
368    "/digest",
369    "/settings/nudges",
370    "/settings/rejected",
371    "/settings/mcp",
372];
373
374/// The label of the conversation part: the join of the labels the run was
375/// admitted with for the parts it quotes — read, never inferred. A part
376/// without a label of its own takes the whole input's (fails closed).
377fn conversation_label(input: &Tainted<Value>) -> agentplane::core::Label {
378    MESSAGE_PARTS
379        .iter()
380        .map(|p| input.label_at(p).unwrap_or_else(|| input.label()).clone())
381        .fold(agentplane::core::Label::trusted(), |acc, l| acc.join(&l))
382}
383
384/// What an attempt decides by, besides its bundle (all journaled).
385#[derive(Debug, Clone, Copy)]
386pub struct Settings<'a> {
387    /// Configured checks' argvs (their prefixes are allowed commands).
388    pub checks: &'a [Vec<String>],
389    /// Exact commands allowed (`[commands] allow`).
390    pub allow: &'a [Vec<String>],
391    /// Allowed command prefixes (`[commands] allow_prefix`).
392    pub allow_prefix: &'a [Vec<String>],
393    /// Model turns.
394    pub max_turns: u32,
395    /// Tool calls.
396    pub max_tool_calls: u32,
397    /// Ask for the model's reasoning summary on every turn.
398    pub reasoning: bool,
399    /// Test files frozen for this attempt.
400    pub frozen: &'a [String],
401    /// A person's approvals for this task, in this run.
402    pub approved: &'a [Object],
403    /// A person's rejections for this task.
404    pub rejected: &'a [crate::tools::policy::Rejection],
405    /// Objects a person approved for every task of this run.
406    pub run_approved: &'a [Object],
407    /// Objects a person approved for one call each (a call a lane stopped,
408    /// decided in its run's batch): this attempt may make each one time.
409    pub approved_once: &'a [Object],
410    /// A person's notes for this task (`nudge`).
411    pub nudges: &'a [String],
412    /// The project's MCP tools for this run.
413    pub mcp: &'a [crate::mcp::McpTool],
414    /// The worktree's absolute (canonical) path.
415    pub root: &'a str,
416    /// Frozen tests may gain lines without approval (`[tests] frozen`).
417    pub frozen_additions: bool,
418    /// How a call needing a person is handled: `"tasks"` (the attempt waits
419    /// on a human task) or `"stop"` (it ends `needs_approval`; lanes).
420    pub asks: &'a str,
421    /// How long a call waits for a person before it is denied as expired.
422    pub wait_secs: u64,
423    /// Exact commands that get the network (`[sandbox] network_allow`).
424    pub network_allow: &'a [Vec<String>],
425    /// Prefixes that get it (`[sandbox] network_allow_prefix`).
426    pub network_allow_prefix: &'a [Vec<String>],
427    /// The run the attempt belongs to.
428    pub run_id: &'a str,
429    /// Prompts the run has shown a person so far.
430    pub asked: u32,
431    /// `[approvals] budget`.
432    pub budget: u32,
433    /// A person is prompted inline (not `--no-prompt`).
434    pub prompts: bool,
435    /// An in-place attempt: git that moves the checkout is denied.
436    pub in_place: bool,
437    /// In place: what git ignored at the run's start, hidden from the tools.
438    pub hidden: &'a [String],
439    /// An autopilot run: a call that would ask is refused instead.
440    pub autopilot: bool,
441    /// A planning task: its unit's directory (worktree-relative), whose
442    /// design documents it writes ([`Policy::planning`]).
443    pub planning: Option<&'a str>,
444}
445
446/// The calls consecutive denials of which end an attempt.
447pub const MAX_CONSECUTIVE_DENIALS: u32 = 3;
448
449/// The digest as the model reads it: facts from the verifier only.
450fn digest_text(digest: &Value) -> String {
451    let mut out = format!(
452        "# Previous attempt\n\nAttempt {} at this task was verified by speccoder and FAILED. Its \
453changes were discarded; the worktree is back where that attempt started. You start fresh — use \
454these facts from the verifier, check them yourself, and do not repeat the mistake.\n",
455        digest["attempt"].as_u64().unwrap_or(1)
456    );
457    let strings = |v: &Value| -> Vec<String> {
458        v.as_array()
459            .map(|a| {
460                a.iter()
461                    .filter_map(|x| x.as_str().map(str::to_owned))
462                    .collect()
463            })
464            .unwrap_or_default()
465    };
466    let reasons = strings(&digest["reasons"]);
467    if !reasons.is_empty() {
468        out.push_str("\n## Verdict reasons\n\n");
469        for r in reasons {
470            out.push_str(&format!("- {r}\n"));
471        }
472    }
473    for c in digest["checks"].as_array().into_iter().flatten() {
474        out.push_str(&format!(
475            "\n## Check `{}` ({}): `{}`\n",
476            c["name"].as_str().unwrap_or_default(),
477            c["status"].as_str().unwrap_or_default(),
478            strings(&c["argv"]).join(" ")
479        ));
480        let tests = strings(&c["failing_tests"]);
481        if !tests.is_empty() {
482            out.push_str(&format!("\nFailing tests: {}\n", tests.join(", ")));
483        }
484        out.push_str(&format!(
485            "\n```text\n{}\n```\n",
486            c["excerpt"].as_str().unwrap_or_default()
487        ));
488    }
489    let uncollected = strings(&digest["uncollected"]);
490    if !uncollected.is_empty() {
491        out.push_str("\n## Tests that never ran\n\n");
492        for l in uncollected {
493            out.push_str(&format!("- {l}\n"));
494        }
495    }
496    let discarded = strings(&digest["discarded"]);
497    if !discarded.is_empty() {
498        out.push_str(&format!(
499            "\n## Files the discarded attempt changed\n\n{}\n",
500            discarded.join("\n")
501        ));
502    }
503    out
504}
505
506fn argvs(v: &Value) -> Vec<Vec<String>> {
507    v.as_array()
508        .map(|a| {
509            a.iter()
510                .map(|argv| {
511                    argv.as_array()
512                        .map(|p| {
513                            p.iter()
514                                .filter_map(|x| x.as_str().map(str::to_owned))
515                                .collect()
516                        })
517                        .unwrap_or_default()
518                })
519                .collect()
520        })
521        .unwrap_or_default()
522}
523
524/// A person's decisions of the journaled input at `key`: none when absent,
525/// an error when present and unreadable — never read as "no decisions",
526/// which would drop rejections.
527fn decisions_of<T: serde::de::DeserializeOwned>(
528    settings: &Value,
529    key: &str,
530) -> Result<Vec<T>, SkillError> {
531    match &settings[key] {
532        Value::Null => Ok(Vec::new()),
533        v => serde_json::from_value(v.clone())
534            .map_err(|e| SkillError::Input(format!("settings.{key}: {e}"))),
535    }
536}
537
538/// The policy an attempt's journaled settings decide by: the checks, the
539/// allow lists, the frozen tests, the person's decisions, the MCP tools,
540/// the network rules, in place and autopilot. An external agent's attempt
541/// is decided by the same one.
542pub(crate) fn policy_of(
543    settings: &Value,
544    rules: &'static crate::format::Rules,
545) -> Result<Policy, SkillError> {
546    let frozen: Vec<String> = settings["frozen"]
547        .as_array()
548        .map(|a| {
549            a.iter()
550                .filter_map(|v| v.as_str().map(str::to_owned))
551                .collect()
552        })
553        .unwrap_or_default();
554    let approved: Vec<Object> = decisions_of(settings, "approved")?;
555    let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(settings, "rejected")?;
556    let mcp: Vec<crate::mcp::McpTool> =
557        serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
558    let policy = Policy::new(&argvs(&settings["checks"]), &argvs(&settings["allow"]))
559        .with_prefixes(argvs(&settings["allow_prefix"]))
560        .with_network_prefixes(argvs(&settings["network_allow_prefix"]))
561        .rules(rules)
562        .with_frozen(frozen)
563        .with_decisions(approved, rejected)
564        .with_mcp(mcp.iter().map(|t| (t.name.clone(), t.decision.clone())))
565        .with_network(argvs(&settings["network_allow"]))
566        .with_hidden(
567            serde_json::from_value::<Vec<String>>(settings["hidden"].clone()).unwrap_or_default(),
568        );
569    let policy = if settings["in_place"] == true {
570        policy.in_place()
571    } else {
572        policy
573    };
574    let policy = if settings["autopilot"] == true {
575        policy.autopilot()
576    } else {
577        policy
578    };
579    Ok(match settings["planning"].as_str() {
580        Some(dir) => policy.planning(dir),
581        None => policy,
582    })
583}
584
585#[async_trait]
586impl Skill for Attempt {
587    fn descriptor(&self) -> SkillDescriptor {
588        SkillDescriptor::new("speccoder.task.attempt").provides(CAPABILITY)
589    }
590
591    async fn invoke(
592        &self,
593        cx: &mut StepCtx<'_>,
594        input: Tainted<Value>,
595    ) -> Result<Outcome, SkillError> {
596        let (ttl, style) = (self.cache_ttl, self.style);
597        let settings = input.peek()["settings"].clone();
598        let task_name = format!(
599            "{}/{}",
600            input.peek()["bundle"]["feature"]
601                .as_str()
602                .unwrap_or_default(),
603            input.peek()["bundle"]["task"]["id"]
604                .as_str()
605                .unwrap_or_default()
606        );
607        let rejected: Vec<crate::tools::policy::Rejection> = decisions_of(&settings, "rejected")?;
608        let told = rejected;
609        let bundle = &input.peek()["bundle"];
610        let stop = StopGate {
611            spec: ["story", "plan", "constitution"]
612                .iter()
613                .filter_map(|k| bundle[*k].as_str())
614                .chain(bundle["task"]["title"].as_str())
615                .collect::<Vec<_>>()
616                .join("\n"),
617            frozen: settings["frozen"]
618                .as_array()
619                .into_iter()
620                .flatten()
621                .filter_map(|v| v.as_str().map(str::to_owned))
622                .collect(),
623            rejected: !told.is_empty(),
624        };
625        let mcp: Vec<crate::mcp::McpTool> =
626            serde_json::from_value(settings["mcp"].clone()).unwrap_or_default();
627        let planning = settings["planning"].as_str().map(str::to_owned);
628        let policy = policy_of(&settings, self.format.rules())?;
629        let max_turns = settings["max_turns"]
630            .as_u64()
631            .map_or(30, |n| u32::try_from(n).unwrap_or(u32::MAX))
632            .max(1);
633        let max_tool_calls = settings["max_tool_calls"]
634            .as_u64()
635            .map_or(100, |n| u32::try_from(n).unwrap_or(u32::MAX));
636        // Same stable prefix as `propose`, with the attempt policy in front;
637        // a retry's digest goes after it, so the cached prefix is shared.
638        let label = conversation_label(&input);
639        let raw = input.peek().clone();
640        let prompt = provenance::labelled_prompt(
641            {
642                let input = raw;
643                let mut p = layout(
644                    &input["bundle"],
645                    ttl,
646                    style,
647                    &self::policy(self.format.wording()),
648                    self.format,
649                );
650                let given: Vec<crate::instructions::Instruction> =
651                    serde_json::from_value(input["instructions"].clone()).unwrap_or_default();
652                if let Some(text) = crate::instructions::section(&given) {
653                    let (key, kind) = match style {
654                        PromptStyle::OpenAi => ("input", "input_text"),
655                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
656                            ("messages", "text")
657                        }
658                    };
659                    if let Some(content) = p[key][0]["content"].as_array_mut() {
660                        content.push(json!({ "type": kind, "text": text }));
661                    }
662                }
663                if let Some(text) = input["test_layout"].as_str().filter(|t| !t.is_empty()) {
664                    let (key, kind) = match style {
665                        PromptStyle::OpenAi => ("input", "input_text"),
666                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
667                            ("messages", "text")
668                        }
669                    };
670                    if let Some(content) = p[key][0]["content"].as_array_mut() {
671                        content.push(json!({ "type": kind, "text": text }));
672                    }
673                }
674                if !input["digest"].is_null() {
675                    let (key, kind) = match style {
676                        PromptStyle::OpenAi => ("input", "input_text"),
677                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
678                            ("messages", "text")
679                        }
680                    };
681                    if let Some(content) = p[key][0]["content"].as_array_mut() {
682                        content
683                            .push(json!({ "type": kind, "text": digest_text(&input["digest"]) }));
684                    }
685                }
686                // A planning task: where its documents go.
687                if let Some(dir) = &planning {
688                    let (key, kind) = match style {
689                        PromptStyle::OpenAi => ("input", "input_text"),
690                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
691                            ("messages", "text")
692                        }
693                    };
694                    let text = format!(
695                        "# A planning task\n\nThis task writes design documents, not code: write them under `{dir}/` (the plan, contracts, the data model, research and the like are yours to write there). The requirements, the task list and the person's review stay protected, as does everything else of the spec; change no source code. speccoder runs the checks afterwards.\n"
696                    );
697                    if let Some(content) = p[key][0]["content"].as_array_mut() {
698                        content.push(json!({ "type": kind, "text": text }));
699                    }
700                }
701                // The checks as configured: the commands that run without asking.
702                let checks: Vec<String> = argvs(&input["settings"]["checks"])
703                    .iter()
704                    .map(|argv| format!("- `{}`", json!(argv)))
705                    .collect();
706                if !checks.is_empty() {
707                    let (key, kind) = match style {
708                        PromptStyle::OpenAi => ("input", "input_text"),
709                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
710                            ("messages", "text")
711                        }
712                    };
713                    let text = format!(
714                        "# The checks\n\nRun them with run_command as these argvs, word for word; they need no approval:\n\n{}\n",
715                        checks.join("\n")
716                    );
717                    if let Some(content) = p[key][0]["content"].as_array_mut() {
718                        content.push(json!({ "type": kind, "text": text }));
719                    }
720                }
721                // A person's notes for this task.
722                let notes: Vec<String> = input["settings"]["nudges"]
723                    .as_array()
724                    .map(|a| {
725                        a.iter()
726                            .filter_map(|n| n.as_str().map(|n| format!("- {n}")))
727                            .collect()
728                    })
729                    .unwrap_or_default();
730                if !notes.is_empty() {
731                    let (key, kind) = match style {
732                        PromptStyle::OpenAi => ("input", "input_text"),
733                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
734                            ("messages", "text")
735                        }
736                    };
737                    let text = format!("# Notes from a person\n\n{}\n", notes.join("\n"));
738                    if let Some(content) = p[key][0]["content"].as_array_mut() {
739                        content.push(json!({ "type": kind, "text": text }));
740                    }
741                }
742                // A person's rejections, so the model does not ask again.
743                if !told.is_empty() {
744                    let (key, kind) = match style {
745                        PromptStyle::OpenAi => ("input", "input_text"),
746                        PromptStyle::Anthropic | PromptStyle::Chat | PromptStyle::Parts { .. } => {
747                            ("messages", "text")
748                        }
749                    };
750                    let lines: Vec<String> = told
751                        .iter()
752                        .map(|r| format!("- {}: {}", r.object.describe(), r.reason))
753                        .collect();
754                    let text = format!(
755                        "# Rejected by a person\n\nThese calls were rejected for this task; find another way or call report_blocked:\n\n{}\n",
756                        lines.join("\n")
757                    );
758                    if let Some(content) = p[key][0]["content"].as_array_mut() {
759                        content.push(json!({ "type": kind, "text": text }));
760                    }
761                }
762                if let PromptStyle::Parts { caches } = style {
763                    super::model_config::to_parts(&mut p, caches);
764                }
765                p
766            },
767            &label,
768        );
769        let mut tools = crate::tools::declarations();
770        tools.push(report_blocked());
771        // MCP tools the policy does not deny, as the journaled input lists them.
772        let routes: Vec<(String, String, String)> = mcp
773            .iter()
774            .filter(|t| t.decision != "deny")
775            .map(|t| (t.name.clone(), t.server.clone(), t.tool.clone()))
776            .collect();
777        tools.extend(mcp.iter().filter(|t| t.decision != "deny").map(|t| {
778            ToolDeclaration::new(t.name.clone(), t.description.clone(), t.schema.clone())
779        }));
780        // Journaled: whether this attempt asks inline decides its effects.
781        let ask = Asker::from_settings(
782            &settings,
783            task_name.clone(),
784            input.peek()["bundle"]["task"]["title"]
785                .as_str()
786                .unwrap_or_default()
787                .to_owned(),
788            PresentContext {
789                root: settings["root"].as_str().map(std::path::PathBuf::from),
790                ..self.present.clone()
791            },
792        );
793        let remembered: Vec<Object> = decisions_of(&settings, "run_approved")?;
794        let once: Vec<Object> = decisions_of(&settings, "approved_once")?;
795        // Untrusted text the prompt itself carries.
796        let mut parts = Vec::new();
797        let given: Vec<crate::instructions::Instruction> =
798            serde_json::from_value(input.peek()["instructions"].clone()).unwrap_or_default();
799        if let Some(text) = crate::instructions::section(&given) {
800            parts.push(crate::approvals::derived::Part {
801                from: provenance::WORKTREE.to_owned(),
802                turn: 0,
803                text,
804            });
805        }
806        if !input.peek()["digest"].is_null() {
807            parts.push(crate::approvals::derived::Part {
808                from: provenance::VERIFIER.to_owned(),
809                turn: 0,
810                text: crate::approvals::derived::text_of(&input.peek()["digest"]),
811            });
812        }
813        let number = |k: &str, d: u32| {
814            settings[k]
815                .as_u64()
816                .map_or(d, |n| u32::try_from(n).unwrap_or(u32::MAX))
817        };
818        tool_loop(
819            cx,
820            &prompt,
821            LoopSpec {
822                tools,
823                policy,
824                max_turns,
825                max_tool_calls,
826                reasoning: settings["reasoning"] == true,
827                schema: Some(report_schema()),
828                max_tokens: None,
829                reads: None,
830                halt: None,
831                routes,
832                root: settings["root"].as_str().map(str::to_owned),
833                frozen_additions: settings["frozen_additions"] == true,
834                ask,
835                run_approved: remembered,
836                once,
837                run: settings["run_id"].as_str().map(str::to_owned),
838                asking: Asking {
839                    asked: number("asked", 0),
840                    budget: number("budget", DEFAULT_BUDGET),
841                    prompts: settings["prompts"] == true,
842                },
843                parts,
844                mcp_digests: mcp
845                    .iter()
846                    .map(|t| (t.name.clone(), t.digest.clone()))
847                    .collect(),
848                stop,
849                observer: self.progress.clone().map(|p| {
850                    (
851                        task_name.rsplit('/').next().unwrap_or_default().to_owned(),
852                        p,
853                    )
854                }),
855                finish: |report| {
856                    // The model's own word that it did not finish is believed;
857                    // its word that it did is not (the verifier decides that).
858                    if report["status"] == "incomplete" {
859                        "incomplete"
860                    } else {
861                        "completed"
862                    }
863                },
864            },
865        )
866        .await
867    }
868}
869
870/// Turns an absolute `path` argument inside the workspace `root` into a
871/// workspace-relative one. Tools print absolute paths (Spec Kit's scripts
872/// do), and a model passes them on; outside the root a path stays as it is
873/// — and the policy refuses it.
874fn relativise(arguments: &mut Value, root: Option<&str>) {
875    let (Some(root), Some(path)) = (root, arguments.get("path").and_then(Value::as_str)) else {
876        return;
877    };
878    let root = root.trim_end_matches('/');
879    if let Some(rel) = path.strip_prefix(root).and_then(|r| r.strip_prefix('/')) {
880        let rel = if rel.is_empty() {
881            ".".to_owned()
882        } else {
883            rel.to_owned()
884        };
885        arguments["path"] = Value::String(rel);
886    }
887}
888
889/// Reports one logged tool call to whoever watches.
890fn emit(
891    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
892    turn: u32,
893    entry: &Value,
894) {
895    emit_with_tail(observer, turn, entry, Vec::new());
896}
897
898/// [`emit`] with the last lines of a failed command's output (live only,
899/// never journaled).
900fn emit_with_tail(
901    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
902    turn: u32,
903    entry: &Value,
904    tail: Vec<String>,
905) {
906    if let Some((task, p)) = observer {
907        let tool = entry["tool"].as_str().unwrap_or_default();
908        p.event(&crate::progress::Event::ToolCall {
909            task: task.clone(),
910            turn,
911            tool: tool.to_owned(),
912            target: crate::progress::target(tool, &entry["arguments"]),
913            decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
914            ok: entry["ok"].as_bool(),
915            tail,
916        });
917    }
918}
919
920/// A person is needed for `asked`: the run tells whoever watches.
921fn needs_person(
922    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
923    asked: &agentplane::model::ToolCall,
924    object: Option<&Object>,
925) {
926    if let Some((task, p)) = observer {
927        let (tool, arguments) = (asked.name.as_str(), &asked.arguments);
928        let target = crate::progress::target(tool, arguments);
929        let what: String = format!("{tool} {target}")
930            .chars()
931            .filter(|c| !c.is_control())
932            .take(60)
933            .collect();
934        p.event(&crate::progress::Event::ApprovalRequested {
935            task: task.clone(),
936            call: asked.id.clone(),
937            tool: tool.to_owned(),
938            what,
939            class: object
940                .map(|o| o.class().as_str().to_owned())
941                .unwrap_or_default(),
942            object: object.map(Object::short).unwrap_or_default(),
943        });
944    }
945}
946
947/// A person decided `asked`: the run tells whoever watches.
948fn person_decided(
949    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
950    asked: &agentplane::model::ToolCall,
951    decision: &str,
952    scope: Option<&str>,
953    who: Option<&Who>,
954    object: Option<&Object>,
955) {
956    if let Some((task, p)) = observer {
957        p.event(&crate::progress::Event::ApprovalDecided {
958            task: task.clone(),
959            call: asked.id.clone(),
960            decision: decision.to_owned(),
961            scope: scope.map(str::to_owned),
962            by: who.map_or_else(|| "nobody (expired)".to_owned(), |w| w.name.clone()),
963            how: who.map_or_else(|| "expired".to_owned(), |w| w.basis.clone()),
964            class: object
965                .map(|o| o.class().as_str().to_owned())
966                .unwrap_or_default(),
967            object: object.map(Object::short).unwrap_or_default(),
968        });
969    }
970}
971
972/// What a call needing a person is asked by.
973struct Question<'a> {
974    asked: &'a agentplane::model::ToolCall,
975    reason: &'a str,
976    turn: u32,
977    said: &'a str,
978    asker: Option<&'a Asker>,
979    policy: &'a Policy,
980    object: Option<&'a Object>,
981    remembered: &'a [(Object, &'static str)],
982    parts: &'a [crate::approvals::derived::Part],
983    run: Option<&'a str>,
984}
985
986/// The journaled question for a call needing a person: its object, what
987/// changed since an approval of the same name, where its arguments were
988/// copied from, and the choices that leaves — all from journaled data.
989fn question(q: &Question<'_>) -> Request {
990    let (tool, arguments) = (q.asked.name.as_str(), &q.asked.arguments);
991    let kind = crate::approvals::kind_of(tool, arguments);
992    let risk = crate::approvals::risk(tool, q.reason);
993    // Always Allow commits an exact entry: judged without the network.
994    let always_risk = if kind == "network" {
995        let mut bare = arguments.clone();
996        if let Some(o) = bare.as_object_mut() {
997            o.remove("network");
998        }
999        match q.policy.base(tool, &bare) {
1000            Decision::Allow => "low",
1001            Decision::Approval(r) => crate::approvals::risk(tool, &r),
1002            Decision::Deny(_) => "high",
1003        }
1004    } else {
1005        risk
1006    };
1007    let (sources, more) = crate::approvals::derived::sources(tool, arguments, q.parts);
1008    let changed = q.object.and_then(|o| {
1009        q.remembered
1010            .iter()
1011            .map(|(r, _)| r)
1012            .chain(q.policy.approved())
1013            .find_map(|earlier| o.changed_from(earlier))
1014    });
1015    Request {
1016        task: q.asker.map(|a| a.task.clone()).unwrap_or_default(),
1017        task_title: q.asker.map(|a| a.task_title.clone()).unwrap_or_default(),
1018        turn: q.turn,
1019        tool: tool.to_owned(),
1020        arguments: arguments.clone(),
1021        kind: kind.to_owned(),
1022        reason: q.reason.to_owned(),
1023        object: q.object.cloned(),
1024        changed,
1025        risk: risk.to_owned(),
1026        claim: crate::approvals::claim(q.said),
1027        options: crate::approvals::options(
1028            risk,
1029            q.object.is_some(),
1030            !sources.is_empty(),
1031            kind,
1032            always_risk,
1033        ),
1034        sources,
1035        more,
1036        run: q.run.map(str::to_owned),
1037        asked: 0,
1038        budget: 0,
1039        batched: false,
1040    }
1041}
1042
1043/// What a governed tool loop offers the model and asks of it.
1044pub(crate) struct LoopSpec {
1045    /// The tools declared to the model; any other name is refused.
1046    pub tools: Vec<ToolDeclaration>,
1047    /// Classifies every call before dispatch.
1048    pub policy: Policy,
1049    /// Model turns.
1050    pub max_turns: u32,
1051    /// Tool calls.
1052    pub max_tool_calls: u32,
1053    /// Ask each turn for the model's reasoning summary.
1054    pub reasoning: bool,
1055    /// The closing answer's schema; `None`: a text answer ends the turn.
1056    pub schema: Option<Value>,
1057    /// Declared tools served elsewhere: (name, server, tool) — MCP.
1058    pub routes: Vec<(String, String, String)>,
1059    /// The workspace's absolute path, from the journaled input: absolute
1060    /// paths inside it are made relative before the policy sees them.
1061    pub root: Option<String>,
1062    /// A write to a frozen test that only adds lines needs no approval.
1063    pub frozen_additions: bool,
1064    /// The status for a closing answer.
1065    pub finish: fn(&Value) -> &'static str,
1066    /// Ask a person instead of stopping when a call needs approval.
1067    pub ask: Option<Asker>,
1068    /// Objects a person approved for this run: allowed without asking.
1069    pub run_approved: Vec<Object>,
1070    /// Objects a person approved for one call each: the first call of
1071    /// each is allowed without asking, later ones ask again.
1072    pub once: Vec<Object>,
1073    /// Live progress: the task ID and where to report.
1074    pub observer: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
1075    /// The run the attempt belongs to (`run-<secs>-<hex>`).
1076    pub run: Option<String>,
1077    /// How a run asks: its count so far, its budget and whether a person is
1078    /// prompted at all.
1079    pub asking: Asking,
1080    /// Untrusted content the prompt carries (inlined worktree text, the
1081    /// verifier's digest): what derived arguments are found in.
1082    pub parts: Vec<crate::approvals::derived::Part>,
1083    /// MCP tools' digests by model-facing name: their objects.
1084    pub mcp_digests: std::collections::BTreeMap<String, String>,
1085    /// What a `report_blocked` call's evidence is checked against.
1086    pub stop: StopGate,
1087    /// Tokens a turn may use (Ask); `None`: the plane's budget alone.
1088    pub max_tokens: Option<u64>,
1089    /// The first read id to issue (`r<n>`), when the answer cites reads.
1090    pub reads: Option<u32>,
1091    /// Pressed when a person stops the turn (Ask): the model call streaming
1092    /// ends where it is (a recorded, metered completion) and none of its
1093    /// calls run. Not effect identity — replay returns the recorded call.
1094    pub halt: Option<agentplane::model::Stop>,
1095}
1096
1097/// A run's asking budget as an attempt sees it (all journaled).
1098#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1099pub struct Asking {
1100    /// Prompts shown to a person in the run so far.
1101    pub asked: u32,
1102    /// `[approvals] budget`: at most this many per run.
1103    pub budget: u32,
1104    /// A person is prompted inline; `false` with `--no-prompt` (calls wait
1105    /// as ordinary approvals, nothing is counted).
1106    pub prompts: bool,
1107}
1108
1109impl Default for Asking {
1110    fn default() -> Self {
1111        Self {
1112            asked: 0,
1113            budget: DEFAULT_BUDGET,
1114            prompts: false,
1115        }
1116    }
1117}
1118
1119/// `[approvals] budget` unless configured.
1120pub use super::run_config::DEFAULT_BUDGET;
1121
1122/// The governed tool loop shared by attempts and convergence: every call is
1123/// classified first (deny → refused and told; approval → stop; allow →
1124/// dispatched through agentplane); the closing structured answer ends it.
1125pub(crate) async fn tool_loop(
1126    cx: &mut StepCtx<'_>,
1127    prompt: &Tainted<Value>,
1128    spec: LoopSpec,
1129) -> Result<Outcome, SkillError> {
1130    // What the conversation has read, with its labels: agentplane checks
1131    // every model call against the join.
1132    let mut transcript = Transcript::new(prompt);
1133    tool_turn(cx, prompt, spec, &mut transcript).await
1134}
1135
1136/// [`tool_loop`] continuing a conversation the caller holds: Ask's turns
1137/// share one transcript, each person's message told before the turn.
1138pub(crate) async fn tool_turn(
1139    cx: &mut StepCtx<'_>,
1140    prompt: &Tainted<Value>,
1141    spec: LoopSpec,
1142    transcript: &mut Transcript,
1143) -> Result<Outcome, SkillError> {
1144    let LoopSpec {
1145        tools,
1146        policy,
1147        max_turns,
1148        max_tool_calls,
1149        reasoning,
1150        schema,
1151        routes,
1152        root,
1153        frozen_additions,
1154        finish,
1155        ask,
1156        run_approved,
1157        mut once,
1158        observer,
1159        run,
1160        asking,
1161        mut parts,
1162        mcp_digests,
1163        stop,
1164        max_tokens,
1165        reads,
1166        halt,
1167    } = spec;
1168    // The next read id this turn issues, when reads are cited.
1169    let mut next_read = reads;
1170    let mut proposed = false;
1171    // Objects allowed without asking: this run's, then what a person
1172    // answered "for the task" or "for this run" in this attempt.
1173    let mut remembered: Vec<(Object, &'static str)> =
1174        run_approved.into_iter().map(|g| (g, "run-grant")).collect();
1175    let mut prompted = asking.asked;
1176    let mut told_budget = false;
1177    let catalog = crate::tools::catalog();
1178
1179    let mut conversation: Label = transcript.label().clone();
1180    let mut calls = 0u32;
1181    let mut denied_in_a_row = 0u32;
1182    // Whether the model was asked once before a stop.
1183    let mut asked_to_stop = false;
1184    // Whether this attempt registered the `approval` obligation yet.
1185    let mut obligation = false;
1186    let mut usage = agentplane::model::Usage::default();
1187    let mut log: Vec<Value> = Vec::new();
1188
1189    let end = |status: &str,
1190               extra: Value,
1191               log: &[Value],
1192               usage: agentplane::model::Usage,
1193               conversation: &Label,
1194               prompted: u32| {
1195        let mut out = json!({
1196            "status": status,
1197            "tool_calls": log,
1198            "asked": prompted,
1199            "usage": {
1200                "input_tokens": usage.input_tokens,
1201                "output_tokens": usage.output_tokens,
1202                "cache_read_tokens": usage.cache_read_tokens,
1203                "cache_write_tokens": usage.cache_write_tokens,
1204            },
1205        });
1206        if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
1207            o.extend(e.clone());
1208        }
1209        Ok(Outcome::done(Tainted::with_label(
1210            out,
1211            conversation.clone(),
1212        )))
1213    };
1214
1215    for turn in 0..max_turns {
1216        // A turn's own token limit, checked before each call.
1217        if let Some(max) = max_tokens
1218            && usage.input_tokens + usage.output_tokens >= max
1219        {
1220            return end(
1221                "budget",
1222                json!({ "reason": format!("this turn used {} of its {max} tokens", usage.input_tokens + usage.output_tokens) }),
1223                &log,
1224                usage,
1225                &conversation,
1226                prompted,
1227            );
1228        }
1229        // The prompt was built from labelled parts (`/system` is speccoder's
1230        // constant instruction); what the conversation read since rides in
1231        // the transcript, so the request is labelled by its real sources.
1232        let tools_now = tools.clone();
1233        let schema_now = schema.clone();
1234        let read_so_far = &*transcript;
1235        let stop_now = halt.clone();
1236        let completion = cx
1237            .complete_with(prompt, move |call| {
1238                let call = call.with_tools(tools_now).continuing(read_so_far);
1239                let call = match stop_now {
1240                    Some(stop) => call.stopped_by(stop),
1241                    None => call,
1242                };
1243                let call = match schema_now {
1244                    Some(schema) => call.expecting(schema),
1245                    None => call,
1246                };
1247                if reasoning {
1248                    call.with_reasoning_summary()
1249                } else {
1250                    call
1251                }
1252            })
1253            .await?;
1254        conversation = transcript.label().join(completion.label());
1255        usage = usage.plus(completion.peek().usage);
1256        if let Some((task, p)) = &observer {
1257            p.event(&crate::progress::Event::Usage {
1258                task: task.clone(),
1259                input_tokens: usage.input_tokens,
1260                output_tokens: usage.output_tokens,
1261                cache_read_tokens: usage.cache_read_tokens,
1262                cache_write_tokens: usage.cache_write_tokens,
1263                usd: None,
1264            });
1265        }
1266
1267        // The model's summary of its reasoning, when asked and given: its
1268        // account of why, a claim like its words.
1269        if let (Some((task, p)), Some(reasoning)) =
1270            (&observer, completion.peek().reasoning.as_deref())
1271            && !reasoning.trim().is_empty()
1272        {
1273            let text: String = crate::approvals::present::escape(reasoning.trim())
1274                .chars()
1275                .take(4000)
1276                .collect();
1277            p.event(&crate::progress::Event::Reasoning {
1278                task: task.clone(),
1279                turn: turn + 1,
1280                text,
1281            });
1282        }
1283        if completion.peek().stopped
1284            || halt
1285                .as_ref()
1286                .is_some_and(agentplane::model::Stop::is_stopped)
1287        {
1288            return end(
1289                "stopped",
1290                json!({ "text": completion.peek().text }),
1291                &log,
1292                usage,
1293                &conversation,
1294                prompted,
1295            );
1296        }
1297        if completion.peek().truncated {
1298            return Ok(Outcome::fail(
1299                "the model ran out of output budget mid-turn; nothing it was still writing was executed — raise [model] max_output_tokens",
1300            ));
1301        }
1302        let asked_calls = transcript.asked(&completion);
1303        // The model's own words beside its calls, live: its account, a
1304        // claim. A turn without calls is its final answer, reported as such;
1305        // its commentary, where the provider marks one, is still words.
1306        let said = super::said(completion.peek());
1307        if let (Some((task, p)), Some(said)) = (observer.as_ref(), said.as_deref()) {
1308            let text: String = crate::approvals::present::escape(said)
1309                .chars()
1310                .take(2000)
1311                .collect();
1312            p.event(&crate::progress::Event::Said {
1313                task: task.clone(),
1314                turn: turn + 1,
1315                text,
1316            });
1317        }
1318        if asked_calls.is_empty() && schema.is_none() {
1319            return end(
1320                "answered",
1321                json!({ "text": completion.peek().text }),
1322                &log,
1323                usage,
1324                &conversation,
1325                prompted,
1326            );
1327        }
1328        if asked_calls.is_empty() {
1329            let report = completion
1330                .peek()
1331                .structured
1332                .clone()
1333                .unwrap_or_else(|| json!({ "summary": completion.peek().text, "status": "incomplete", "files_changed": [], "checks_run": [], "notes": ["the answer was not the requested structure"] }));
1334            let status = finish(&report);
1335            return end(
1336                status,
1337                json!({ "report": report }),
1338                &log,
1339                usage,
1340                &conversation,
1341                prompted,
1342            );
1343        }
1344
1345        for mut asked in asked_calls {
1346            relativise(&mut asked.arguments, root.as_deref());
1347            // Only speccoder sets the object a call is checked against.
1348            if let Some(o) = asked.arguments.as_object_mut() {
1349                o.remove(crate::tools::EXPECT);
1350            }
1351            calls += 1;
1352            if calls > max_tool_calls {
1353                return end(
1354                    "budget_exhausted",
1355                    json!({ "reason": format!("more than {max_tool_calls} tool calls (`[budget] max_tool_calls`)") }),
1356                    &log,
1357                    usage,
1358                    &conversation,
1359                    prompted,
1360                );
1361            }
1362            if asked.name == ASK_PERSON && tools.iter().any(|t| t.name == ASK_PERSON) {
1363                let question = asked.arguments["question"]
1364                    .as_str()
1365                    .unwrap_or_default()
1366                    .to_owned();
1367                // A person's answer is their input, recorded with who gave it
1368                // — never speccoder's own trusted text.
1369                let decided = match &ask {
1370                    Some(asker) => Some(
1371                        wait_for_person(
1372                            cx,
1373                            asker,
1374                            &mut obligation,
1375                            &crate::approvals::tasks::question_task(&asker.task, &question),
1376                        )
1377                        .await?,
1378                    ),
1379                    None => None,
1380                };
1381                let read = decided.as_ref().map(crate::approvals::tasks::read);
1382                let by = read.as_ref().and_then(|r| r.by.clone());
1383                let entry = json!({
1384                    "tool": ASK_PERSON,
1385                    "arguments": asked.arguments,
1386                    "decision": "allow",
1387                    "answered": read.as_ref().is_some_and(|r| r.text.is_some()),
1388                    // The person's words, as the record of what they said.
1389                    "answer": read.as_ref().and_then(|r| r.text.clone()),
1390                    "operator": by.as_ref().map(Who::view),
1391                    "via": by.as_ref().map(|w| w.via.clone()),
1392                });
1393                emit(observer.as_ref(), turn + 1, &entry);
1394                log.push(entry);
1395                let text = match (&read, &by) {
1396                    (Some(r), _) if r.text.is_some() => r.text.clone().unwrap_or_default(),
1397                    (Some(_), Some(w)) => format!("{} declined to answer.", w.name),
1398                    _ => "Nobody can answer now. Record the question in the artifact as [NEEDS CLARIFICATION: <question>] and continue.".to_owned(),
1399                };
1400                let label = match &by {
1401                    Some(_) => provenance::from(provenance::PERSON),
1402                    None => Label::trusted(),
1403                };
1404                transcript.answered(
1405                    asked,
1406                    &Tainted::with_label(json!({ "answer": text }), label),
1407                );
1408                continue;
1409            }
1410            if asked.name == REPORT_BLOCKED && tools.iter().any(|t| t.name == REPORT_BLOCKED) {
1411                let refused = stop.rejected || log.iter().any(|e| e["decision"] == "deny");
1412                let left = max_turns - turn - 1;
1413                match judge_stop(&stop, &asked.arguments, refused, asked_to_stop, left) {
1414                    Stop::Stopped(reason) => {
1415                        let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "stop" });
1416                        emit(observer.as_ref(), turn + 1, &entry);
1417                        log.push(entry);
1418                        return end(
1419                            "blocked",
1420                            json!({ "reason": reason }),
1421                            &log,
1422                            usage,
1423                            &conversation,
1424                            prompted,
1425                        );
1426                    }
1427                    Stop::Asked(text) => {
1428                        asked_to_stop = true;
1429                        let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "challenged" });
1430                        emit(observer.as_ref(), turn + 1, &entry);
1431                        log.push(entry);
1432                        transcript.answered(
1433                            asked,
1434                            &Tainted::trusted(json!({ "stopped": false, "note": text })),
1435                        );
1436                    }
1437                    Stop::Refused(text) => {
1438                        let entry = json!({ "tool": REPORT_BLOCKED, "arguments": asked.arguments, "decision": "unproven", "reason": text });
1439                        emit(observer.as_ref(), turn + 1, &entry);
1440                        log.push(entry);
1441                        transcript.failed(asked, Tainted::trusted(text));
1442                    }
1443                }
1444                continue;
1445            }
1446            if asked.name == crate::ask::PROPOSE && tools.iter().any(|t| t.name == asked.name) {
1447                // A suggestion for the person: nothing runs, one per turn.
1448                let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": if proposed { "deny" } else { "proposed" } });
1449                emit(observer.as_ref(), turn + 1, &entry);
1450                log.push(entry);
1451                if proposed {
1452                    transcript.failed(
1453                        asked,
1454                        Tainted::trusted(
1455                            "one proposal per turn; this one was not shown".to_owned(),
1456                        ),
1457                    );
1458                } else {
1459                    proposed = true;
1460                    transcript.answered(asked, &Tainted::trusted(json!({ "shown": true, "note": "shown to the person as a sheet; nothing ran, and only the person can confirm it" })));
1461                }
1462                continue;
1463            }
1464            let declared = tools.iter().any(|t| t.name == asked.name);
1465            let target = catalog
1466                .iter()
1467                .find(|t| declared && t.name == asked.name)
1468                .map(|t| ToolId::new(t.server, t.name))
1469                .or_else(|| {
1470                    routes
1471                        .iter()
1472                        .find(|(n, _, _)| declared && *n == asked.name)
1473                        .map(|(_, server, tool)| ToolId::new(server, tool))
1474                });
1475            let Some(target) = target else {
1476                let entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "unknown" });
1477                emit(observer.as_ref(), turn + 1, &entry);
1478                log.push(entry);
1479                transcript.failed(asked, Tainted::trusted("no tool of that name".to_owned()));
1480                continue;
1481            };
1482            let mut decision = policy.base(&asked.name, &asked.arguments);
1483            // `[tests] frozen = "additions"`: read the frozen test (a
1484            // journaled call, so replay sees the same content) and allow a
1485            // write that keeps every existing line.
1486            let mut additions_only = false;
1487            if frozen_additions
1488                && matches!(&decision, Decision::Approval(r) if r.contains("frozen test"))
1489            {
1490                let path = asked.arguments["path"]
1491                    .as_str()
1492                    .unwrap_or_default()
1493                    .to_owned();
1494                let read = cx
1495                    .call_tool(
1496                        ToolId::new("fs", "read_file"),
1497                        Tainted::with_label(json!({ "path": path }), completion.label().clone()),
1498                    )
1499                    .await?;
1500                let current = read.peek().clone();
1501                // `read_file` joins lines with `\n`; the file's last newline is
1502                // restored so an edit's `old_string` matches as on disk.
1503                let lines = current["content"].as_str().unwrap_or_default();
1504                let old_text = if lines.is_empty() {
1505                    String::new()
1506                } else {
1507                    format!("{lines}\n")
1508                };
1509                let old = old_text.as_str();
1510                let readable = current["ok"] == true && current["truncated"] != true;
1511                let new = match asked.name.as_str() {
1512                    "write_file" => asked.arguments["content"].as_str().map(str::to_owned),
1513                    "edit_file" => {
1514                        let (from, to) = (
1515                            asked.arguments["old_string"].as_str().unwrap_or_default(),
1516                            asked.arguments["new_string"].as_str().unwrap_or_default(),
1517                        );
1518                        let all = asked.arguments["replace_all"] == true;
1519                        (!from.is_empty() && (all || old.matches(from).count() == 1))
1520                            .then(|| old.replace(from, to))
1521                    }
1522                    _ => None,
1523                };
1524                if readable && new.is_some_and(|n| crate::integrity::only_additions(old, &n)) {
1525                    decision = Decision::Allow;
1526                    additions_only = true;
1527                }
1528            }
1529            // A person would be needed: the object the call reaches is
1530            // resolved by a journaled step (replay reads the same object
1531            // back), and only a decision on that very object applies.
1532            let mut object: Option<Object> = None;
1533            if matches!(decision, Decision::Approval(_)) {
1534                if asked.name.starts_with(crate::mcp::PREFIX) {
1535                    object = mcp_digests.get(&asked.name).map(|digest| Object::Tool {
1536                        name: asked.name.clone(),
1537                        digest: digest.clone(),
1538                    });
1539                } else {
1540                    let resolved = cx
1541                        .call_tool(
1542                            ToolId::new("fs", "resolve"),
1543                            Tainted::with_label(
1544                                json!({ "tool": asked.name, "arguments": asked.arguments }),
1545                                completion.label().clone(),
1546                            ),
1547                        )
1548                        .await?;
1549                    let out = resolved.peek();
1550                    if out["ok"] == true {
1551                        object = serde_json::from_value(out["object"].clone()).ok();
1552                    } else {
1553                        decision = Decision::Deny(
1554                            out["error"]
1555                                .as_str()
1556                                .unwrap_or("the call's object cannot be resolved")
1557                                .to_owned(),
1558                        );
1559                    }
1560                }
1561                if matches!(decision, Decision::Approval(_)) {
1562                    decision = policy.classify(&asked.name, &asked.arguments, object.as_ref());
1563                }
1564            }
1565            let mut person: Option<Value> = None;
1566            // Allowed by a person's grant for the task (of this run): the
1567            // person's decision, not a rule's.
1568            if decision == Decision::Allow && object.is_some() {
1569                person = Some(json!({
1570                    "approved_by": "person",
1571                    "scope": "task-grant",
1572                    "grant": { "scope": "task", "run": run },
1573                }));
1574            }
1575            let mut note: Option<String> = None;
1576            // An object a person approved for one call: this one.
1577            if matches!(decision, Decision::Approval(_))
1578                && let Some(at) = object
1579                    .as_ref()
1580                    .and_then(|o| once.iter().position(|r| r == o))
1581            {
1582                once.remove(at);
1583                person = Some(json!({
1584                    "approved_by": "person",
1585                    "scope": "once-grant",
1586                    "grant": { "scope": "once", "run": run },
1587                }));
1588                decision = Decision::Allow;
1589            }
1590            // An object a person already approved for this run or task.
1591            if matches!(decision, Decision::Approval(_))
1592                && let Some((_, scope)) = object
1593                    .as_ref()
1594                    .and_then(|o| remembered.iter().find(|(r, _)| r == o))
1595            {
1596                person = Some(json!({
1597                    "approved_by": "person",
1598                    "scope": scope,
1599                    "grant": { "scope": scope.trim_end_matches("-grant"), "run": run },
1600                }));
1601                decision = Decision::Allow;
1602            }
1603            // On autopilot nobody is asked: what would wait for a person is
1604            // refused for the run and listed for them afterwards.
1605            decision = policy.settle(decision);
1606            // Still a person's: the question, with everything it is decided by.
1607            let request = match &decision {
1608                Decision::Approval(reason) => Some(question(&Question {
1609                    asked: &asked,
1610                    reason,
1611                    turn: turn + 1,
1612                    said: said.as_deref().unwrap_or_default(),
1613                    asker: ask.as_ref(),
1614                    policy: &policy,
1615                    object: object.as_ref(),
1616                    remembered: &remembered,
1617                    parts: &parts,
1618                    run: run.as_deref(),
1619                })),
1620                _ => None,
1621            };
1622            // ask a person now, as a journaled effect, instead of stopping.
1623            if let (Some(mut request), Some(asker)) = (request.clone(), &ask) {
1624                // The run's asking budget: within it a person is prompted
1625                // (and counted, whatever they answer); past it the call
1626                // waits in the run's batch, nobody interrupted.
1627                if asking.prompts {
1628                    if prompted < asking.budget {
1629                        prompted += 1;
1630                        request.asked = prompted;
1631                    } else {
1632                        request.batched = true;
1633                    }
1634                    request.budget = asking.budget;
1635                }
1636                if request.batched {
1637                    // The batch item is created: the person is notified
1638                    // once; calls joining it notify no one.
1639                    if !told_budget {
1640                        needs_person(observer.as_ref(), &asked, object.as_ref());
1641                    }
1642                    if !told_budget && let Some((_, p)) = &observer {
1643                        p.event(&crate::progress::Event::Notice {
1644                            text: format!(
1645                                "approval budget ({}) reached: calls wait in Needs You (speccoder approvals)",
1646                                asking.budget
1647                            ),
1648                        });
1649                    }
1650                    told_budget = true;
1651                } else {
1652                    needs_person(observer.as_ref(), &asked, object.as_ref());
1653                }
1654                let decided = wait_for_person(
1655                    cx,
1656                    asker,
1657                    &mut obligation,
1658                    &crate::approvals::tasks::approval_task(&request, &log),
1659                )
1660                .await?;
1661                let read = crate::approvals::tasks::read(&decided);
1662                // A batch decision may start the run's asking again.
1663                if read.resume_asking {
1664                    prompted = 0;
1665                }
1666                let answer: Answer = read.answer.clone();
1667                // Who decided, and how that is known, is journaled with it.
1668                let approver = serde_json::to_value(&answer.by).unwrap_or(Value::Null);
1669                let operator = read.by.as_ref().map(Who::view);
1670                person_decided(
1671                    observer.as_ref(),
1672                    &asked,
1673                    if answer.decision == Said::Approve {
1674                        "approve"
1675                    } else {
1676                        "reject"
1677                    },
1678                    (answer.decision == Said::Approve).then(|| answer.scope.name()),
1679                    read.by.as_ref().filter(|_| !read.expired),
1680                    object.as_ref(),
1681                );
1682                match answer.decision {
1683                    Said::Approve => {
1684                        let mut p = json!({
1685                            "approved_by": "person",
1686                            "scope": answer.scope.name(),
1687                            "note": answer.note,
1688                            "approver": approver,
1689                            "operator": operator,
1690                        });
1691                        if answer.always {
1692                            p["always"] = json!(true);
1693                        }
1694                        if let (Some(o), Scope::Task | Scope::Run) = (&object, answer.scope) {
1695                            let remembered_as = if answer.scope == Scope::Run {
1696                                "run-grant"
1697                            } else {
1698                                "task-grant"
1699                            };
1700                            p["grant"] = json!({ "scope": answer.scope.name(), "run": run });
1701                            remembered.push((o.clone(), remembered_as));
1702                        }
1703                        person = Some(p);
1704                        note = answer.note;
1705                        decision = Decision::Allow;
1706                    }
1707                    // "Later" is a task left undecided: the run stays
1708                    // suspended and never reaches here.
1709                    Said::Reject | Said::Stop => {
1710                        let why = answer
1711                            .reason
1712                            .unwrap_or_else(|| "no reason given".to_owned());
1713                        person = Some(if read.expired {
1714                            json!({ "expired": true, "rejection": why })
1715                        } else {
1716                            json!({ "rejected_by": "person", "approver": approver, "operator": operator, "rejection": why })
1717                        });
1718                        decision = Decision::Deny(format!("a person rejected this: {why}"));
1719                    }
1720                }
1721            }
1722            // Who decided: a person (now, or by a grant they gave), or a rule
1723            // — named, so a rule's decision is never read as a person's.
1724            let rule = person
1725                .is_none()
1726                .then(|| policy.rule(&asked.name, &asked.arguments, object.as_ref()));
1727            let with_person = |mut entry: Value| {
1728                if let Some(o) = entry.as_object_mut() {
1729                    // The object decided on, whoever decided.
1730                    if let Some(obj) = &object {
1731                        o.insert("object".to_owned(), json!(obj));
1732                    }
1733                    match (person.as_ref().and_then(Value::as_object), &rule) {
1734                        (Some(p), _) => {
1735                            // An expiry is nobody's decision.
1736                            let by = if p.contains_key("expired") {
1737                                "expiry"
1738                            } else {
1739                                "person"
1740                            };
1741                            o.insert("by".to_owned(), json!(by));
1742                            o.extend(p.clone());
1743                        }
1744                        (None, Some(r)) => {
1745                            o.insert("by".to_owned(), json!("rule"));
1746                            o.insert("rule".to_owned(), json!(r));
1747                        }
1748                        (None, None) => {}
1749                    }
1750                }
1751                entry
1752            };
1753            match decision {
1754                Decision::Deny(reason) => {
1755                    let entry = with_person(
1756                        json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "deny", "reason": reason }),
1757                    );
1758                    emit(observer.as_ref(), turn + 1, &entry);
1759                    log.push(entry);
1760                    // The reason quotes the call (its tool, path, command, or a
1761                    // person's words about it): it carries the call's label.
1762                    transcript.failed(
1763                        asked,
1764                        refusal(format!("denied: {reason}"), completion.label()),
1765                    );
1766                    // Escalation: a model that keeps asking for what it may
1767                    // not do is stopped for a person.
1768                    denied_in_a_row += 1;
1769                    if denied_in_a_row >= MAX_CONSECUTIVE_DENIALS {
1770                        return end(
1771                            "denied",
1772                            json!({ "reason": format!("{denied_in_a_row} calls in a row were denied; last: {reason}") }),
1773                            &log,
1774                            usage,
1775                            &conversation,
1776                            prompted,
1777                        );
1778                    }
1779                    continue;
1780                }
1781                Decision::Approval(reason) => {
1782                    let mut entry = json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "approval", "reason": reason });
1783                    if let Some(obj) = &object {
1784                        entry["object"] = json!(obj);
1785                    }
1786                    emit(observer.as_ref(), turn + 1, &entry);
1787                    needs_person(observer.as_ref(), &asked, object.as_ref());
1788                    log.push(entry);
1789                    return end(
1790                        "needs_approval",
1791                        json!({ "pending": {
1792                            "tool": asked.name,
1793                            "arguments": asked.arguments,
1794                            "reason": reason,
1795                            "object": object,
1796                            "run": run,
1797                            "request": request,
1798                        } }),
1799                        &log,
1800                        usage,
1801                        &conversation,
1802                        prompted,
1803                    );
1804                }
1805                Decision::Allow => {
1806                    denied_in_a_row = 0;
1807                    // The arguments carry the model's label: agentplane's
1808                    // taint gate checks them against the grant's fields (an
1809                    // argument-less mutating tool is granted exactly `{}`).
1810                    // A command gets the call's id (`SPECCODER_CALL_ID`):
1811                    // a call resumed after a crash has the same one, so a
1812                    // script can tell a repeat from a new call.
1813                    let mut arguments = asked.arguments.clone();
1814                    if asked.name == "run_command"
1815                        && let Some(o) = arguments.as_object_mut()
1816                    {
1817                        o.insert("call_id".to_owned(), Value::String(asked.id.clone()));
1818                    }
1819                    // Decided on an object: the tool checks it again at
1820                    // use, and refuses when the call now reaches another.
1821                    if let (Some(obj), Some(o)) = (&object, arguments.as_object_mut())
1822                        && matches!(
1823                            asked.name.as_str(),
1824                            "write_file" | "edit_file" | "run_command"
1825                        )
1826                    {
1827                        o.insert(crate::tools::EXPECT.to_owned(), json!(obj));
1828                    }
1829                    let args = Tainted::with_label(arguments, completion.label().clone());
1830                    let result = match cx.call_tool(target.clone(), args).await {
1831                        Ok(r) => r,
1832                        // A failure the far side reported is the model's to
1833                        // read, labelled by who wrote it.
1834                        Err(error) => match cx.relayed(&error) {
1835                            Some(detail) => {
1836                                let entry = with_person(
1837                                    json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": false }),
1838                                );
1839                                emit(observer.as_ref(), turn + 1, &entry);
1840                                log.push(entry);
1841                                transcript.failed(asked, detail);
1842                                continue;
1843                            }
1844                            None => return Err(error.into()),
1845                        },
1846                    };
1847                    let output = result.peek().clone();
1848                    let ok = output["ok"].as_bool() != Some(false);
1849                    let mut entry = with_person(
1850                        json!({ "tool": asked.name, "arguments": asked.arguments, "decision": "allow", "ok": ok }),
1851                    );
1852                    // A read the answer may cite: its id rides on the result.
1853                    let result = match next_read.as_mut() {
1854                        Some(n) if ok => {
1855                            let id = format!("r{n}");
1856                            *n += 1;
1857                            entry["read"] = json!(crate::ask::read_of(
1858                                &id,
1859                                &asked.name,
1860                                &asked.arguments,
1861                                &output
1862                            ));
1863                            result.map(|mut v| {
1864                                if let Some(o) = v.as_object_mut() {
1865                                    o.insert("read".to_owned(), json!(id));
1866                                }
1867                                v
1868                            })
1869                        }
1870                        _ => result,
1871                    };
1872                    if let Some(changed) = output["object_changed"].as_object() {
1873                        entry["decision"] = json!("refused_changed");
1874                        entry["expected"] = changed.get("expected").cloned().unwrap_or(Value::Null);
1875                        entry["found"] = changed.get("found").cloned().unwrap_or(Value::Null);
1876                    }
1877                    // What the tool returned is content the attempt read:
1878                    // arguments copied from it later are named as such.
1879                    if let Some(part) = crate::approvals::derived::part_of(
1880                        &asked.name,
1881                        &asked.arguments,
1882                        &output,
1883                        turn + 1,
1884                        &format!("tool://{}/{}", target.server, target.tool),
1885                        |p: &str| {
1886                            policy.protected().write_class(p)
1887                                == Some(crate::tools::paths::Class::Intent)
1888                        },
1889                    ) {
1890                        parts.push(part);
1891                    }
1892                    if additions_only {
1893                        entry["frozen_additions"] = json!(true);
1894                    }
1895                    let tail = if asked.name == "run_command" && !ok {
1896                        crate::progress::tail(&format!(
1897                            "{}\n{}",
1898                            output["stdout"].as_str().unwrap_or_default(),
1899                            output["stderr"].as_str().unwrap_or_default()
1900                        ))
1901                    } else {
1902                        Vec::new()
1903                    };
1904                    emit_with_tail(observer.as_ref(), turn + 1, &entry, tail);
1905                    log.push(entry);
1906                    // A command that changed a judge input ends the attempt
1907                    // before the model sees another turn.
1908                    if let Some(drift) = output["integrity"].as_array().filter(|d| !d.is_empty()) {
1909                        let paths: Vec<&str> =
1910                            drift.iter().filter_map(|d| d["path"].as_str()).collect();
1911                        return end(
1912                            "integrity",
1913                            json!({
1914                                "reason": format!("a command changed what judges the task: {}", paths.join(", ")),
1915                                "integrity": drift,
1916                            }),
1917                            &log,
1918                            usage,
1919                            &conversation,
1920                            prompted,
1921                        );
1922                    }
1923                    // A tool that refused (`error`) is a failed call; a
1924                    // command that ran and exited non-zero is a result the
1925                    // model must read in full — its stdout and stderr are
1926                    // the diagnostics (found by the first real evaluation,
1927                    // where the model saw only the word "failed").
1928                    match output["error"].as_str() {
1929                        Some(error) if !ok => transcript.failed(
1930                            asked,
1931                            Tainted::with_label(error.to_owned(), result.label().clone()),
1932                        ),
1933                        // A person's note rides with the result it approved.
1934                        _ => match &note {
1935                            Some(n) => {
1936                                let n = format!("Note from the person who approved this call: {n}");
1937                                transcript.answered(
1938                                    asked,
1939                                    &result.map(|mut v| {
1940                                        v["note_from_person"] = Value::String(n);
1941                                        v
1942                                    }),
1943                                );
1944                            }
1945                            None => transcript.answered(asked, &result),
1946                        },
1947                    }
1948                }
1949            }
1950        }
1951    }
1952    end(
1953        "budget_exhausted",
1954        json!({ "reason": format!("more than {max_turns} model turns (`[budget] max_turns`)") }),
1955        &log,
1956        usage,
1957        &conversation,
1958        prompted,
1959    )
1960}
1961
1962#[cfg(test)]
1963mod tests {
1964    use super::*;
1965
1966    #[test]
1967    fn labels_are_read_from_the_parts_and_refusals_carry_the_call() {
1968        use agentplane::core::{Label, SourceId};
1969        let model = Label::untrusted(SourceId::new("model:x/y"));
1970        // A refusal quoting the call is the call's; a constant is trusted.
1971        assert!(
1972            refusal("denied: `src/x` is protected".to_owned(), &model)
1973                .label()
1974                .is_untrusted()
1975        );
1976        // Parts labelled when admitted: the spec trusted, the digest the verifier's.
1977        let input = Tainted::object([
1978            ("bundle", Tainted::trusted(json!({}))),
1979            (
1980                "digest",
1981                Tainted::with_label(json!({}), provenance::from(provenance::VERIFIER)),
1982            ),
1983            (
1984                "settings",
1985                Tainted::object([("nudges", Tainted::trusted(json!([])))]),
1986            ),
1987        ]);
1988        let label = conversation_label(&input);
1989        assert!(
1990            label
1991                .provenance
1992                .contains(&SourceId::new(provenance::VERIFIER))
1993        );
1994        // No labels of its own: every part takes the whole input's (fails closed).
1995        let flat = Tainted::with_label(json!({ "bundle": {} }), model.clone());
1996        assert_eq!(conversation_label(&flat).provenance, model.provenance);
1997    }
1998
1999    #[test]
2000    fn absolute_paths_inside_the_root_become_relative_and_others_stay() {
2001        let mut a = json!({ "path": "/work/repo/specs/001-x/plan.md" });
2002        relativise(&mut a, Some("/work/repo"));
2003        assert_eq!(a["path"], "specs/001-x/plan.md");
2004        let mut outside = json!({ "path": "/work/repository-two/x" });
2005        relativise(&mut outside, Some("/work/repo"));
2006        assert_eq!(outside["path"], "/work/repository-two/x");
2007        let mut none = json!({ "path": "/etc/passwd" });
2008        relativise(&mut none, None);
2009        assert_eq!(none["path"], "/etc/passwd");
2010    }
2011
2012    /// A stop is its evidence: what speccoder cannot check goes on, the
2013    /// rest is asked once, a refused call stops at once.
2014    #[test]
2015    fn a_stop_stands_on_evidence_speccoder_checks() {
2016        let gate = StopGate {
2017            spec: "Rows are sorted\n  ascending by name.".to_owned(),
2018            frozen: vec!["tests/test_us3.py".to_owned()],
2019            rejected: false,
2020        };
2021        let call = |kind: &str, evidence: &str| json!({ "kind": kind, "evidence": evidence, "reason": "why" });
2022        let judge =
2023            |args: &Value, refused: bool, asked: bool| judge_stop(&gate, args, refused, asked, 5);
2024        let unproven = |s: Stop| matches!(s, Stop::Refused(_));
2025
2026        // The T018 stop: scope is no kind, an entry point is no evidence.
2027        assert!(unproven(judge(
2028            &json!({ "reason": "beyond the task's module scope" }),
2029            false,
2030            false
2031        )));
2032        assert!(unproven(judge(&call("scope", "src/cli"), false, false)));
2033        assert!(unproven(judge(
2034            &call("wrong_test", "tests/new_test.py"),
2035            false,
2036            false
2037        )));
2038        assert!(unproven(judge(
2039            &call("spec_conflict", "sorted descending by name"),
2040            false,
2041            false
2042        )));
2043        assert!(unproven(judge(
2044            &call("spec_conflict", "sorted"),
2045            false,
2046            false
2047        )));
2048        assert!(unproven(judge(
2049            &call("refused", "cargo add x"),
2050            false,
2051            false
2052        )));
2053
2054        // Checked evidence is asked about once, with the turns left; the
2055        // second call stops, its reason first.
2056        let quote = call("spec_conflict", "sorted ascending by name");
2057        assert!(
2058            matches!(judge(&quote, false, false), Stop::Asked(t) if t.contains("5 turns are left"))
2059        );
2060        assert_eq!(
2061            judge(&quote, false, true),
2062            Stop::Stopped("why — the spec says “sorted ascending by name”".to_owned())
2063        );
2064        assert_eq!(
2065            judge(&call("wrong_test", "./tests/test_us3.py"), false, true),
2066            Stop::Stopped("why — frozen test `./tests/test_us3.py`".to_owned())
2067        );
2068        // A refused call needs no second word, nor does the last turn.
2069        assert!(matches!(
2070            judge(&call("refused", "cargo add x"), true, false),
2071            Stop::Stopped(_)
2072        ));
2073        let q = call("needs_information", "which locale?");
2074        assert!(matches!(
2075            judge_stop(&gate, &q, false, false, 0),
2076            Stop::Stopped(_)
2077        ));
2078    }
2079}