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