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