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