1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
//! Session-local control for the model-entered Plan phase.
//!
//! Core owns the `enter_plan_mode` / `exit_plan_mode` tools, but the embedding
//! session owns their state. Keeping that state behind an injected collaborator
//! prevents one TUI session (or one concurrent test) from changing another.
/// Session-local state behind `enter_plan_mode` and `exit_plan_mode`.
///
/// Entering Plan can only attenuate the active turn. The dispatcher consults
/// [`Self::is_plan_mode`] before every tool call, so a successful enter takes
/// effect immediately for later calls in the same model tool round.
pub trait PlanModeControl: Send + Sync {
/// Whether the model-entered Plan phase is currently active.
fn is_plan_mode(&self) -> bool;
/// Enter or leave the model-entered Plan phase.
///
/// Implementations should update only state owned by the current session.
fn set_plan_mode(&self, active: bool) -> Result<(), String>;
/// #2424: the model called `exit_plan_mode` this round, asking for the
/// clamp to be lifted. Does NOT lift it — recorded separately from
/// [`Self::is_plan_mode`] so calls made between the request and the
/// turn's end still see the clamp. Only the turn-end approval hook may
/// actually call `set_plan_mode(false)`.
fn request_exit(&self) -> Result<(), String>;
/// Whether an approval request is pending, without consuming it.
///
/// Provider loops use this after recording a tool batch to yield to the
/// caller's approval hook. The clamp and request stay intact until that
/// hook handles the operator's answer. Legacy embedders that do not expose
/// this query retain their existing turn-end handoff behavior.
fn exit_requested(&self) -> bool {
false
}
/// Take-and-clear whether [`Self::request_exit`] was called since the
/// last take. The turn-end hook calls this once, at most, per turn.
fn take_exit_requested(&self) -> bool;
/// Whether the current turn is the implementing turn an approval
/// seeded. The executor's plan-before-act arm does not ask about a plan
/// the operator just approved when the model records it in the ledger.
/// Embedders that never seed such a turn keep the default.
fn implementing_approved_plan(&self) -> bool {
false
}
}
/// One revision of the model's in-progress Plan-phase draft.
///
/// design: `docs/design/plan-mode-draft-present-approve.md`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanDraft {
/// Monotonic per-session revision number, starting at 1.
pub revision: u32,
/// The composed Markdown `render_report` would otherwise have displayed.
pub markdown: String,
}
/// The exact draft the operator was shown, identified by its content — the
/// snapshot an approval binds to. Approval seeds implementation from THIS,
/// never from whatever the mutable draft slot holds later, so a draft saved
/// after presentation (or under another objective) cannot ride an earlier
/// "yes". The id is `RawContentId` over the Markdown bytes (a `PlanDraft` is
/// an opaque byte payload here, not a structured record).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PresentedPlan {
pub draft: PlanDraft,
pub id: content_addressable::RawContentId,
}
impl PresentedPlan {
pub fn new(draft: PlanDraft) -> Self {
let id = content_addressable::RawContentId::from_content(draft.markdown.as_bytes());
Self { draft, id }
}
}
/// Session-local sink for the model's in-progress Plan-phase draft.
///
/// Under the Plan disposition, `render_report` replaces this draft instead of
/// printing it — one draft slot, not a growing transcript of near-identical
/// displays. Implementations own where the draft is persisted (e.g. the
/// session's `plan.md`); newt-core gains no filesystem authority from this
/// trait, and the model's own clamp is untouched because the harness does the
/// write.
pub trait PlanDraftSink: Send + Sync {
/// Replace the draft with `markdown`. Returns the new revision number
/// (the prior revision plus one; the first save is revision 1).
fn save_draft(&self, markdown: String) -> Result<u32, String>;
/// The latest saved draft this session, if any `render_report` call has
/// landed one yet. Used to present exactly once at the end of a planning
/// turn — never polled mid-turn to decide model-facing behavior.
fn latest_draft(&self) -> Option<PlanDraft>;
}
/// How the model-entered Plan phase now clamping the turn was reached.
/// Decides what an approval may do (design:
/// `docs/design/plan-mode-draft-present-approve.md`, #2424) — the caveats a
/// turn started with are the ceiling an approval inside it may restore, never
/// widen past.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanEntry {
/// The model called `enter_plan_mode` inside an already-validated Act
/// turn. The turn's own Act disposition was checked at turn start; the
/// Plan clamp is a LOCAL, later-arriving restriction on top of it.
/// Approval can simply lift the local clamp and let the same turn
/// continue — it restores authority the turn already held, mints none.
ModelDuringAct,
/// Intake resolved this turn as Plan from the start (the observed bug
/// case): the turn's own caveats were met with `plan_phase_clamp()` at
/// turn start, so there is no wider Act disposition to fall back into.
/// Approval must end this turn and seed a fresh Act turn with the
/// approved plan — lifting the clamp mid-turn here would mint authority
/// the turn was never validated for.
IntakeInferred,
/// The operator explicitly set `/mode plan`. Approval offers switching
/// to `/mode dev` as the operator's own act, then proceeds exactly like
/// [`Self::IntakeInferred`].
OperatorSelected,
}
impl PlanEntry {
/// Whether an approval may resume the SAME turn (true only when that
/// turn's own disposition was already Act — see [`Self::ModelDuringAct`]).
#[must_use]
pub fn resumes_same_turn(self) -> bool {
matches!(self, Self::ModelDuringAct)
}
}
/// What an approval question resolved to. Never mints authority — see
/// [`PlanEntry`]'s own doc for why the two variants differ in scope.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanVerdict {
/// Approved. [`PlanEntry::resumes_same_turn`] on the entry that produced
/// this verdict says whether the caller may continue the current turn or
/// must end it and seed a fresh Act turn with the approved plan.
Approved,
/// Stay clamped read-only. `feedback`, when present, is the operator's
/// own text (a rejection reason, a "discuss" follow-up, or anything
/// else typed instead of an unambiguous yes/no) — fed back as the next
/// operator prompt. `None` means a plain no, or no operator answer was
/// obtained at all (unavailable/cancelled/exit/closed/failed all fail
/// closed to this, per [`HumanQuestionOutcome`]'s own variants).
StayClamped { feedback: Option<String> },
}
/// The three answers a plan question offers, in presentation order. `yes`
/// is the default for a blank submission (`[Y/n/discuss]`). Cancellation or
/// unavailable input is not a submission and never approves. `discuss` opens
/// a free-text follow-up whose text is fed back to the model.
pub const PLAN_APPROVAL_CHOICES: &[super::permissions::ChoiceSpec] = &[
super::permissions::ChoiceSpec {
id: "yes",
label: "yes — implement this plan",
key: "y",
role: newt_interaction::SemanticRole::Allow,
},
super::permissions::ChoiceSpec {
id: "no",
label: "no — stay in plan mode",
key: "n",
role: newt_interaction::SemanticRole::Deny,
},
super::permissions::ChoiceSpec {
id: "discuss",
label: "discuss — tell the model what to change",
key: "d",
role: newt_interaction::SemanticRole::Value,
},
];
/// Map a submitted answer to approval, decline or verbatim feedback.
/// Blank, `y`/`yes`, and bare continuation answers approve; `n`/`no` declines.
/// Non-answer outcomes always retain the clamp, regardless of the default.
#[must_use]
pub fn plan_verdict(
_entry: PlanEntry,
outcome: super::permissions::HumanQuestionOutcome,
) -> PlanVerdict {
use super::permissions::HumanQuestionOutcome;
let HumanQuestionOutcome::Answer(text) = outcome else {
// Unavailable, Cancelled, ExitRequested, InputClosed, InputFailed —
// none of them carry an answer, so none of them can approve.
return PlanVerdict::StayClamped { feedback: None };
};
let trimmed = text.trim();
if trimmed.is_empty()
|| trimmed.eq_ignore_ascii_case("y")
|| trimmed.eq_ignore_ascii_case("yes")
{
return PlanVerdict::Approved;
}
// "go", "do it", "proceed", "ship it": the operator's natural yes at a
// plan question. The one continuation table decides what counts.
if crate::classifiers::is_bare_continuation(trimmed) {
return PlanVerdict::Approved;
}
if trimmed.eq_ignore_ascii_case("n") || trimmed.eq_ignore_ascii_case("no") {
return PlanVerdict::StayClamped { feedback: None };
}
PlanVerdict::StayClamped {
feedback: Some(text),
}
}
#[cfg(test)]
mod tests {
use super::super::permissions::HumanQuestionOutcome;
use super::*;
/// The operator-selected default approves an actually submitted blank;
/// missing input remains a separate, non-answer outcome.
#[test]
fn plan_default_yes_blank_submission_approves() {
for entry in [
PlanEntry::IntakeInferred,
PlanEntry::ModelDuringAct,
PlanEntry::OperatorSelected,
] {
for text in ["", " ", "\t\n"] {
assert_eq!(
plan_verdict(entry, HumanQuestionOutcome::Answer(text.into())),
PlanVerdict::Approved
);
}
}
}
/// Selection UIs select the first offered answer when Enter is pressed.
#[test]
fn plan_default_yes_is_the_first_choice() {
assert_eq!(
PLAN_APPROVAL_CHOICES
.iter()
.map(|choice| choice.id)
.collect::<Vec<_>>(),
["yes", "no", "discuss"]
);
}
#[test]
fn yes_approves_case_insensitively_and_trimmed() {
for text in ["y", "Y", "yes", "YES", " y ", "Yes"] {
assert_eq!(
plan_verdict(
PlanEntry::IntakeInferred,
HumanQuestionOutcome::Answer(text.to_string())
),
PlanVerdict::Approved,
"{text:?} must approve"
);
}
}
#[test]
fn a_bare_continuation_at_the_question_approves() {
for text in ["go", "do it", "proceed", "go ahead", "ship it", "Continue."] {
assert_eq!(
plan_verdict(
PlanEntry::ModelDuringAct,
HumanQuestionOutcome::Answer(text.to_string())
),
PlanVerdict::Approved,
"{text:?} must approve"
);
}
}
#[test]
fn no_declines_with_no_feedback() {
for text in ["n", "N", "no", "NO"] {
assert_eq!(
plan_verdict(
PlanEntry::IntakeInferred,
HumanQuestionOutcome::Answer(text.to_string())
),
PlanVerdict::StayClamped { feedback: None },
"{text:?} must decline with no feedback"
);
}
}
#[test]
fn anything_else_typed_stays_clamped_and_is_fed_back_verbatim() {
for text in ["discuss the caching step", "edit", "what about tests?"] {
assert_eq!(
plan_verdict(
PlanEntry::IntakeInferred,
HumanQuestionOutcome::Answer(text.to_string())
),
PlanVerdict::StayClamped {
feedback: Some(text.to_string())
},
"{text:?} must be kept verbatim as feedback"
);
}
}
#[test]
fn every_non_answer_outcome_fails_closed_to_stay_clamped() {
for outcome in [
HumanQuestionOutcome::Unavailable,
HumanQuestionOutcome::Cancelled,
HumanQuestionOutcome::ExitRequested,
HumanQuestionOutcome::InputClosed,
HumanQuestionOutcome::InputFailed,
] {
for entry in [
PlanEntry::IntakeInferred,
PlanEntry::ModelDuringAct,
PlanEntry::OperatorSelected,
] {
assert_eq!(
plan_verdict(entry, outcome.clone()),
PlanVerdict::StayClamped { feedback: None }
);
}
}
}
#[test]
fn only_model_during_act_resumes_the_same_turn() {
assert!(PlanEntry::ModelDuringAct.resumes_same_turn());
assert!(!PlanEntry::IntakeInferred.resumes_same_turn());
assert!(!PlanEntry::OperatorSelected.resumes_same_turn());
}
}