buildwithnexus 0.10.4 → 0.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +1 -0
  2. package/harness/Cargo.lock +6 -5
  3. package/harness/Cargo.toml +2 -8
  4. package/harness/src/agent.rs +2665 -246
  5. package/harness/src/bundled_skills/code-review.md +17 -0
  6. package/harness/src/bundled_skills/codebase-repair.md +32 -0
  7. package/harness/src/bundled_skills/data-analysis.md +16 -0
  8. package/harness/src/bundled_skills/decision_support.md +59 -0
  9. package/harness/src/bundled_skills/document-generation.md +29 -0
  10. package/harness/src/bundled_skills/frontend-ux.md +18 -0
  11. package/harness/src/bundled_skills/git-release.md +23 -0
  12. package/harness/src/bundled_skills/release-notes.md +17 -0
  13. package/harness/src/bundled_skills/research.md +17 -0
  14. package/harness/src/bundled_skills/rust-cli.md +26 -0
  15. package/harness/src/bundled_skills/security-review.md +19 -0
  16. package/harness/src/bundled_skills/self-knowledge.md +104 -0
  17. package/harness/src/bundled_skills/spec-writing.md +19 -0
  18. package/harness/src/bundled_skills/static-app.md +36 -0
  19. package/harness/src/bundled_skills/test-engineering.md +18 -0
  20. package/harness/src/bundled_skills/tool-use.md +52 -0
  21. package/harness/src/bundled_skills/verification_workflow.md +62 -0
  22. package/harness/src/checkpoint.rs +105 -8
  23. package/harness/src/config.rs +533 -88
  24. package/harness/src/hooks.rs +306 -52
  25. package/harness/src/knowledge.rs +433 -0
  26. package/harness/src/lib.rs +2127 -249
  27. package/harness/src/local.rs +13 -4
  28. package/harness/src/onboarding.rs +91 -21
  29. package/harness/src/provider.rs +346 -79
  30. package/harness/src/report.rs +37 -13
  31. package/harness/src/rules.rs +467 -0
  32. package/harness/src/session.rs +36 -9
  33. package/harness/src/tools.rs +3021 -114
  34. package/harness/src/trace.rs +218 -0
  35. package/harness/src/tui.rs +2174 -237
  36. package/harness/src/verifier.rs +375 -0
  37. package/harness/src/workflow.rs +72 -32
  38. package/package.json +1 -1
  39. package/scripts/postinstall.js +4 -1
  40. package/scripts/resolve-binary.js +2 -0
@@ -3,21 +3,27 @@
3
3
  // but ALL modes have access to the full tool surface so the model can grep,
4
4
  // fetch, read files, and run commands regardless of which mode the user is in.
5
5
 
6
+ use std::collections::{HashMap, HashSet};
6
7
  use std::io::IsTerminal;
7
8
  use std::path::{Path, PathBuf};
8
9
  use std::process::Command;
9
10
  use std::sync::atomic::{AtomicUsize, Ordering};
11
+ use std::sync::Mutex;
10
12
 
11
13
  use crate::config;
12
14
  use crate::hooks::{self, PreDecision};
13
15
  use crate::provider::{self, complete, Msg, Provider, Reply, ToolResult};
14
16
  use crate::report;
15
17
  use crate::tools;
18
+ use crate::trace;
16
19
  use crate::tui;
17
20
 
18
21
  const MAX_ITERS: usize = 30;
19
22
  const MAX_DEPTH: usize = 3;
20
23
  const KEEP_RECENT: usize = 6;
24
+ const MAX_IDENTICAL_TOOL_RESULTS: usize = 2;
25
+ const MAX_PLAN_TOOL_ROUNDS: usize = 10;
26
+ const MAX_CHAT_TOOL_ROUNDS: usize = 12;
21
27
 
22
28
  // ── context compaction ────────────────────────────────────────────────────────
23
29
  fn estimate_tokens(msgs: &[Msg]) -> usize {
@@ -25,9 +31,15 @@ fn estimate_tokens(msgs: &[Msg]) -> usize {
25
31
  for m in msgs {
26
32
  chars += match m {
27
33
  Msg::System(s) | Msg::User(s) => s.len(),
28
- Msg::UserImages { text, images } => text.len() + images.iter().map(|(_, d)| d.len() / 3).sum::<usize>(),
34
+ Msg::UserImages { text, images } => {
35
+ text.len() + images.iter().map(|(_, d)| d.len() / 3).sum::<usize>()
36
+ }
29
37
  Msg::Assistant { text, calls } => {
30
- text.len() + calls.iter().map(|c| c.name.len() + c.input.to_string().len()).sum::<usize>()
38
+ text.len()
39
+ + calls
40
+ .iter()
41
+ .map(|c| c.name.len() + c.input.to_string().len())
42
+ .sum::<usize>()
31
43
  }
32
44
  Msg::Tool(rs) => rs.iter().map(|r| r.content.len()).sum(),
33
45
  };
@@ -36,7 +48,10 @@ fn estimate_tokens(msgs: &[Msg]) -> usize {
36
48
  }
37
49
 
38
50
  fn compaction_split(msgs: &[Msg]) -> (usize, usize) {
39
- let sys_end = msgs.iter().take_while(|m| matches!(m, Msg::System(_))).count();
51
+ let sys_end = msgs
52
+ .iter()
53
+ .take_while(|m| matches!(m, Msg::System(_)))
54
+ .count();
40
55
  let tail_start = msgs.len().saturating_sub(KEEP_RECENT).max(sys_end);
41
56
  (sys_end, tail_start)
42
57
  }
@@ -57,12 +72,30 @@ fn structural_summary(middle: &[Msg]) -> String {
57
72
  }
58
73
  }
59
74
 
75
+ fn assistant_tool_turn_text(text: String, calls: &[provider::ToolCall]) -> String {
76
+ if calls.is_empty() || text.len() <= 500 {
77
+ return text;
78
+ }
79
+ let actions = calls
80
+ .iter()
81
+ .map(|c| tools::preview(&c.name, &c.input))
82
+ .collect::<Vec<_>>()
83
+ .join("; ");
84
+ format!("Requested tool call(s): {actions}")
85
+ }
86
+
60
87
  fn render_msgs(msgs: &[Msg]) -> String {
61
88
  let mut s = String::new();
62
89
  for m in msgs {
63
90
  match m {
64
- Msg::System(t) => { s.push_str("system: "); s.push_str(t); }
65
- Msg::User(t) => { s.push_str("user: "); s.push_str(t); }
91
+ Msg::System(t) => {
92
+ s.push_str("system: ");
93
+ s.push_str(t);
94
+ }
95
+ Msg::User(t) => {
96
+ s.push_str("user: ");
97
+ s.push_str(t);
98
+ }
66
99
  Msg::UserImages { text, images } => {
67
100
  s.push_str(&format!("user [+{} image(s)]: ", images.len()));
68
101
  s.push_str(text);
@@ -88,6 +121,8 @@ fn render_msgs(msgs: &[Msg]) -> String {
88
121
 
89
122
  fn compact_with(msgs: Vec<Msg>, summarize: impl FnOnce(&[Msg]) -> String) -> Vec<Msg> {
90
123
  let (sys_end, tail_start) = compaction_split(&msgs);
124
+ // Even if there's nothing to summarize, truncate bloated tool results
125
+ let msgs = truncate_tool_results(msgs);
91
126
  if tail_start <= sys_end {
92
127
  return msgs;
93
128
  }
@@ -97,11 +132,52 @@ fn compact_with(msgs: Vec<Msg>, summarize: impl FnOnce(&[Msg]) -> String) -> Vec
97
132
  let tail: Vec<Msg> = it.collect();
98
133
  let summary = summarize(&middle);
99
134
  let mut v = system;
100
- v.push(Msg::User(format!("[Summary of earlier conversation, compacted to save context]\n{summary}")));
135
+ v.push(Msg::User(format!(
136
+ "[Summary of earlier conversation, compacted to save context]\n{summary}"
137
+ )));
101
138
  v.extend(tail);
102
139
  v
103
140
  }
104
141
 
142
+ /// Truncate oversized tool result contents to keep context manageable.
143
+ /// Any individual tool result > 800 chars gets clipped.
144
+ fn truncate_tool_results(msgs: Vec<Msg>) -> Vec<Msg> {
145
+ const MAX_RESULT_CHARS: usize = 800;
146
+ msgs.into_iter()
147
+ .map(|m| match m {
148
+ Msg::Tool(results) => Msg::Tool(
149
+ results
150
+ .into_iter()
151
+ .map(|mut r| {
152
+ if r.content.len() > MAX_RESULT_CHARS {
153
+ let truncated: String =
154
+ r.content.chars().take(MAX_RESULT_CHARS).collect();
155
+ r.content = format!(
156
+ "{truncated}\n…(truncated, {} chars total)",
157
+ r.content.len()
158
+ );
159
+ }
160
+ r
161
+ })
162
+ .collect(),
163
+ ),
164
+ Msg::Assistant { text, calls } => {
165
+ // Also trim overly verbose assistant text in tool turns
166
+ let trimmed = if text.len() > 1200 && !calls.is_empty() {
167
+ assistant_tool_turn_text(text, &calls)
168
+ } else {
169
+ text
170
+ };
171
+ Msg::Assistant {
172
+ text: trimmed,
173
+ calls,
174
+ }
175
+ }
176
+ other => other,
177
+ })
178
+ .collect()
179
+ }
180
+
105
181
  fn model_summary(p: &Provider, middle: &[Msg]) -> String {
106
182
  let sys = "Summarize this AI coding-agent conversation into a terse brief that preserves the task, key findings, decisions, files changed, and the current state. Drop pleasantries; use compact bullet points.";
107
183
  let q = vec![Msg::System(sys.into()), Msg::User(render_msgs(middle))];
@@ -125,6 +201,579 @@ fn maybe_compact(p: &Provider, msgs: &mut Vec<Msg>) {
125
201
  *msgs = compact_with(taken, |middle| model_summary(p, middle));
126
202
  }
127
203
 
204
+ #[derive(Default)]
205
+ struct ToolLoopGuard {
206
+ seen: HashMap<String, ToolLoopRecord>,
207
+ }
208
+
209
+ struct ToolLoopRecord {
210
+ content: String,
211
+ is_error: bool,
212
+ count: usize,
213
+ }
214
+
215
+ impl ToolLoopGuard {
216
+ fn note(
217
+ &mut self,
218
+ name: &str,
219
+ input: &serde_json::Value,
220
+ content: &str,
221
+ is_error: bool,
222
+ ) -> Option<String> {
223
+ let key = format!(
224
+ "{name}:{}",
225
+ serde_json::to_string(input).unwrap_or_default()
226
+ );
227
+ let rec = self.seen.entry(key).or_insert_with(|| ToolLoopRecord {
228
+ content: String::new(),
229
+ is_error,
230
+ count: 0,
231
+ });
232
+ if rec.content == content && rec.is_error == is_error {
233
+ rec.count += 1;
234
+ } else {
235
+ rec.content = content.to_string();
236
+ rec.is_error = is_error;
237
+ rec.count = 1;
238
+ }
239
+ if rec.count >= MAX_IDENTICAL_TOOL_RESULTS {
240
+ Some(repeated_tool_summary(name, input, content, is_error))
241
+ } else {
242
+ None
243
+ }
244
+ }
245
+ }
246
+
247
+ fn repeated_tool_summary(
248
+ name: &str,
249
+ input: &serde_json::Value,
250
+ content: &str,
251
+ is_error: bool,
252
+ ) -> String {
253
+ let preview = tools::preview(name, input);
254
+ let shown = content
255
+ .lines()
256
+ .filter(|l| !l.trim().is_empty())
257
+ .take(20)
258
+ .collect::<Vec<_>>()
259
+ .join("\n");
260
+ if is_error {
261
+ format!(
262
+ "I stopped a repeated tool loop. `{preview}` returned the same error more than once:\n{shown}"
263
+ )
264
+ } else if shown.is_empty() {
265
+ format!("I stopped a repeated tool loop. `{preview}` returned no output more than once.")
266
+ } else {
267
+ format!(
268
+ "I stopped a repeated tool loop. `{preview}` returned the same result more than once, so here is the result I found:\n{shown}"
269
+ )
270
+ }
271
+ }
272
+
273
+ fn note_loop_result(
274
+ guard: &mut ToolLoopGuard,
275
+ name: &str,
276
+ input: &serde_json::Value,
277
+ content: &str,
278
+ is_error: bool,
279
+ summary: &mut Option<String>,
280
+ ) {
281
+ if summary.is_some() {
282
+ return;
283
+ }
284
+ if let Some(loop_msg) = guard.note(name, input, content, is_error) {
285
+ report::assistant(&loop_msg);
286
+ *summary = Some(loop_msg);
287
+ }
288
+ }
289
+
290
+ fn tool_input_for_execution(
291
+ name: &str,
292
+ input: &serde_json::Value,
293
+ cwd: &Path,
294
+ phase: &str,
295
+ depth: usize,
296
+ task: &str,
297
+ ) -> serde_json::Value {
298
+ let Some(repaired) = repair_placeholder_tool_input(name, input, cwd, task) else {
299
+ return input.clone();
300
+ };
301
+ report::notice(&format!(
302
+ " recovery: using current workspace for placeholder/root path in {name}"
303
+ ));
304
+ trace::record_visible(
305
+ "tool_input_repaired",
306
+ format!("{name} path repaired"),
307
+ serde_json::json!({
308
+ "tool": name,
309
+ "original": input,
310
+ "repaired": &repaired,
311
+ "cwd": cwd.to_string_lossy(),
312
+ "phase": phase,
313
+ "depth": depth,
314
+ }),
315
+ );
316
+ repaired
317
+ }
318
+
319
+ fn repair_placeholder_tool_input(
320
+ name: &str,
321
+ input: &serde_json::Value,
322
+ cwd: &Path,
323
+ task: &str,
324
+ ) -> Option<serde_json::Value> {
325
+ if matches!(name, "write" | "write_file") {
326
+ if let Some(repaired) = repair_write_file_input(input, task) {
327
+ return Some(repaired);
328
+ }
329
+ }
330
+
331
+ let keys: &[&str] = match name {
332
+ "list" | "list_dir" | "list_tree" | "file_info" => &["path", "filePath"],
333
+ "glob" | "grep" => &["root", "path"],
334
+ "find_paths" | "find_files" | "grep_files" => &["root"],
335
+ _ => return None,
336
+ };
337
+
338
+ let mut repaired = input.clone();
339
+ let mut changed = false;
340
+ let cwd_str = cwd.to_string_lossy().to_string();
341
+ for key in keys {
342
+ if let Some(value) = repaired.get_mut(*key) {
343
+ let Some(path) = value.as_str() else {
344
+ continue;
345
+ };
346
+ if is_placeholder_path(path) || should_repair_filesystem_root(path, task) {
347
+ *value = serde_json::Value::String(cwd_str.clone());
348
+ changed = true;
349
+ }
350
+ }
351
+ }
352
+ changed.then_some(repaired)
353
+ }
354
+
355
+ fn repair_write_file_input(input: &serde_json::Value, task: &str) -> Option<serde_json::Value> {
356
+ let requested = auto_create_file_input(task)?;
357
+ let requested_path = requested["path"].as_str().unwrap_or("");
358
+ let requested_content = requested["content"].as_str().unwrap_or("");
359
+ let path = input
360
+ .get("path")
361
+ .or_else(|| input.get("filePath"))
362
+ .and_then(|v| v.as_str())
363
+ .unwrap_or("");
364
+ let content = input
365
+ .get("content")
366
+ .or_else(|| input.get("contents"))
367
+ .and_then(|v| v.as_str())
368
+ .unwrap_or("");
369
+ let should_repair = path.trim().is_empty()
370
+ || matches!(path.trim(), "~" | "." | "/" | "./")
371
+ || is_placeholder_path(path)
372
+ || (path == requested_path && content != requested_content);
373
+ should_repair.then_some(requested)
374
+ }
375
+
376
+ fn should_repair_filesystem_root(path: &str, task: &str) -> bool {
377
+ path.trim() == "/"
378
+ && task_targets_current_workspace(task)
379
+ && !task_explicitly_targets_root(task)
380
+ }
381
+
382
+ fn task_targets_current_workspace(task: &str) -> bool {
383
+ let lower = task.to_ascii_lowercase();
384
+ [
385
+ "this project",
386
+ "current project",
387
+ "the project",
388
+ "this repo",
389
+ "current repo",
390
+ "repository",
391
+ "workspace",
392
+ "codebase",
393
+ "top-level files",
394
+ "top level files",
395
+ ]
396
+ .iter()
397
+ .any(|needle| lower.contains(needle))
398
+ }
399
+
400
+ fn task_explicitly_targets_root(task: &str) -> bool {
401
+ let lower = task.to_ascii_lowercase();
402
+ [
403
+ "filesystem root",
404
+ "file system root",
405
+ "root filesystem",
406
+ "root file system",
407
+ "list /",
408
+ "inspect /",
409
+ "search /",
410
+ "from /",
411
+ "under /",
412
+ "starting at /",
413
+ "starting from /",
414
+ ]
415
+ .iter()
416
+ .any(|needle| lower.contains(needle))
417
+ }
418
+
419
+ fn is_placeholder_path(path: &str) -> bool {
420
+ let lower = path.trim().to_ascii_lowercase();
421
+ if lower.is_empty() || lower == "." || lower == "./" || lower == "~" {
422
+ return false;
423
+ }
424
+ lower.contains("/path/to/")
425
+ || lower.contains("path/to/your")
426
+ || lower.contains("your/project")
427
+ || lower.contains("your-project")
428
+ || lower.contains("your_project")
429
+ || lower.contains("project-directory")
430
+ || lower.contains("project_directory")
431
+ || matches!(
432
+ lower.as_str(),
433
+ "/path/to/project" | "path/to/project" | "<project-path>" | "{project-path}"
434
+ )
435
+ }
436
+
437
+ #[derive(Default)]
438
+ struct ThinkingStream {
439
+ active: bool,
440
+ step: usize,
441
+ }
442
+
443
+ impl ThinkingStream {
444
+ fn push(&mut self, chunk: &str) {
445
+ if report::is_json() {
446
+ return;
447
+ }
448
+ tui::poll_typeahead();
449
+ let mut rest = chunk.replace('\r', "");
450
+ if rest.is_empty() {
451
+ return;
452
+ }
453
+ while let Some(nl) = rest.find('\n') {
454
+ let part = rest[..nl].trim_end();
455
+ if !part.is_empty() || self.active {
456
+ self.start();
457
+ tui::write_stream(&tui::dim(part));
458
+ tui::write_stream("\n");
459
+ self.active = false;
460
+ }
461
+ rest = rest[nl + 1..].to_string();
462
+ }
463
+ if !rest.is_empty() {
464
+ self.start();
465
+ tui::write_stream(&tui::dim(&rest));
466
+ }
467
+ }
468
+
469
+ fn finish(&mut self) {
470
+ if self.active {
471
+ tui::write_stream("\n");
472
+ self.active = false;
473
+ }
474
+ tui::render_queued_composer();
475
+ }
476
+
477
+ fn start(&mut self) {
478
+ if !self.active {
479
+ let spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
480
+ let s = spinners[self.step % spinners.len()];
481
+ self.step += 1;
482
+ tui::write_stream(&format!(" {} {} ", tui::cyan(s), tui::dim("thinking ›")));
483
+ self.active = true;
484
+ }
485
+ }
486
+ }
487
+
488
+ fn request_reply(
489
+ p: &Provider,
490
+ msgs: &[Msg],
491
+ defs: &[tools::ToolDef],
492
+ label: &str,
493
+ ) -> Result<Reply, String> {
494
+ if report::is_json() {
495
+ let r = complete(p, msgs, defs)?;
496
+ report::assistant(&r.text);
497
+ return Ok(r);
498
+ }
499
+ tui::line(&format!(
500
+ " {} {} · {} [{}]",
501
+ tui::yellow("⚡"),
502
+ tui::bold(label),
503
+ tui::cyan(&format!("reasoning with {}", p.model)),
504
+ tui::dim(&format!("{} tools available", defs.len()))
505
+ ));
506
+ let thinking = std::cell::RefCell::new(ThinkingStream::default());
507
+ let mut streamed = false;
508
+ let mut renderer = tui::StreamRenderer::new();
509
+ let res = provider::stream(
510
+ p,
511
+ msgs,
512
+ defs,
513
+ &mut |c| {
514
+ thinking.borrow_mut().finish();
515
+ renderer.push(c);
516
+ tui::poll_typeahead();
517
+ tui::render_queued_composer();
518
+ streamed = true;
519
+ },
520
+ &mut |t| {
521
+ thinking.borrow_mut().push(t);
522
+ },
523
+ );
524
+ thinking.borrow_mut().finish();
525
+ let r = res?;
526
+ renderer.flush();
527
+ if streamed {
528
+ report::assistant_end();
529
+ }
530
+ Ok(r)
531
+ }
532
+
533
+ static TEXT_TOOL_SEQ: AtomicUsize = AtomicUsize::new(0);
534
+
535
+ fn normalize_text_tool_calls(mut reply: Reply, defs: &[tools::ToolDef], user_text: &str) -> Reply {
536
+ if !reply.calls.is_empty() {
537
+ return reply;
538
+ }
539
+ let Some(calls) = parse_text_tool_calls(&reply.text, defs) else {
540
+ return reply;
541
+ };
542
+ if is_casual_turn(user_text)
543
+ && calls
544
+ .iter()
545
+ .any(|call| tools::is_mutating_call(&call.name, &call.input))
546
+ {
547
+ if !report::is_json() {
548
+ report::notice(" recovery: ignored unrelated tool JSON for casual input");
549
+ }
550
+ trace::record_visible(
551
+ "tool_input_repaired",
552
+ "ignored casual-turn tool JSON",
553
+ serde_json::json!({"calls": calls.iter().map(|c| &c.name).collect::<Vec<_>>()}),
554
+ );
555
+ reply.text = "Hi. I am here and ready. Tell me what you want to build, inspect, or change."
556
+ .to_string();
557
+ reply.calls.clear();
558
+ if !report::is_json() {
559
+ report::assistant(&reply.text);
560
+ }
561
+ return reply;
562
+ }
563
+ if !report::is_json() {
564
+ report::notice(&format!(
565
+ " recovery: parsed {} tool call{} from model JSON",
566
+ calls.len(),
567
+ if calls.len() == 1 { "" } else { "s" }
568
+ ));
569
+ }
570
+ trace::record_visible(
571
+ "tool_input_repaired",
572
+ "parsed text JSON tool call",
573
+ serde_json::json!({"calls": calls.iter().map(|c| &c.name).collect::<Vec<_>>()}),
574
+ );
575
+ reply.text.clear();
576
+ reply.calls = calls;
577
+ reply
578
+ }
579
+
580
+ fn is_casual_turn(text: &str) -> bool {
581
+ let normalized = text
582
+ .trim()
583
+ .trim_matches(|c: char| c.is_ascii_punctuation() || c.is_whitespace())
584
+ .to_ascii_lowercase();
585
+ matches!(
586
+ normalized.as_str(),
587
+ "hi" | "hello"
588
+ | "hey"
589
+ | "yo"
590
+ | "sup"
591
+ | "thanks"
592
+ | "thank you"
593
+ | "ok"
594
+ | "okay"
595
+ | "cool"
596
+ | "nice"
597
+ )
598
+ }
599
+
600
+ fn parse_text_tool_calls(text: &str, defs: &[tools::ToolDef]) -> Option<Vec<provider::ToolCall>> {
601
+ let candidate = extract_json_tool_candidate(text)?;
602
+ let value = serde_json::from_str::<serde_json::Value>(candidate).ok()?;
603
+ let names = defs.iter().map(|d| d.name).collect::<HashSet<_>>();
604
+ let mut calls = Vec::new();
605
+ collect_text_tool_calls(&value, &names, &mut calls);
606
+ (!calls.is_empty()).then_some(calls)
607
+ }
608
+
609
+ fn extract_json_tool_candidate(text: &str) -> Option<&str> {
610
+ let trimmed = text.trim();
611
+ if trimmed.starts_with('{') || trimmed.starts_with('[') {
612
+ return Some(trimmed);
613
+ }
614
+ let rest = trimmed.strip_prefix("```")?;
615
+ let fence_end = rest.find('\n')?;
616
+ let lang = rest[..fence_end].trim().to_ascii_lowercase();
617
+ if !lang.is_empty() && lang != "json" {
618
+ return None;
619
+ }
620
+ let body = &rest[fence_end + 1..];
621
+ let close = body.rfind("```")?;
622
+ let after = body[close + 3..].trim();
623
+ if !after.is_empty() {
624
+ return None;
625
+ }
626
+ Some(body[..close].trim())
627
+ }
628
+
629
+ fn collect_text_tool_calls(
630
+ value: &serde_json::Value,
631
+ allowed: &HashSet<&'static str>,
632
+ out: &mut Vec<provider::ToolCall>,
633
+ ) {
634
+ if let Some(items) = value.as_array() {
635
+ for item in items {
636
+ collect_text_tool_calls(item, allowed, out);
637
+ }
638
+ return;
639
+ }
640
+ let Some(obj) = value.as_object() else {
641
+ return;
642
+ };
643
+ if let Some(items) = obj.get("tool_calls").and_then(|v| v.as_array()) {
644
+ for item in items {
645
+ collect_text_tool_calls(item, allowed, out);
646
+ }
647
+ return;
648
+ }
649
+ if let Some(items) = obj.get("calls").and_then(|v| v.as_array()) {
650
+ for item in items {
651
+ collect_text_tool_calls(item, allowed, out);
652
+ }
653
+ return;
654
+ }
655
+ if let Some(function) = obj.get("function").and_then(|v| v.as_object()) {
656
+ let Some(name) = function.get("name").and_then(|v| v.as_str()) else {
657
+ return;
658
+ };
659
+ if !allowed.contains(name) {
660
+ return;
661
+ }
662
+ let input = function
663
+ .get("arguments")
664
+ .and_then(|v| v.as_str())
665
+ .and_then(|s| serde_json::from_str(s).ok())
666
+ .unwrap_or_else(|| serde_json::json!({}));
667
+ out.push(text_tool_call(name, input));
668
+ return;
669
+ }
670
+ let Some(name) = obj
671
+ .get("name")
672
+ .or_else(|| obj.get("tool_name"))
673
+ .and_then(|v| v.as_str())
674
+ else {
675
+ return;
676
+ };
677
+ if !allowed.contains(name) {
678
+ return;
679
+ }
680
+ let input = obj
681
+ .get("arguments")
682
+ .or_else(|| obj.get("input"))
683
+ .cloned()
684
+ .unwrap_or_else(|| {
685
+ let mut map = serde_json::Map::new();
686
+ for (k, v) in obj {
687
+ if k != "name" && k != "tool_name" && k != "type" && k != "id" {
688
+ map.insert(k.clone(), v.clone());
689
+ }
690
+ }
691
+ serde_json::Value::Object(map)
692
+ });
693
+ out.push(text_tool_call(name, input));
694
+ }
695
+
696
+ fn text_tool_call(name: &str, input: serde_json::Value) -> provider::ToolCall {
697
+ let id = TEXT_TOOL_SEQ.fetch_add(1, Ordering::Relaxed);
698
+ provider::ToolCall {
699
+ id: format!("text-tool-{id}"),
700
+ name: name.to_string(),
701
+ input,
702
+ }
703
+ }
704
+
705
+ fn answer_question(input: &serde_json::Value) -> (String, bool) {
706
+ let question = if let Some(qs) = input["questions"].as_array() {
707
+ let mut acc = Vec::new();
708
+ for item in qs {
709
+ if let Some(s) = item.as_str() {
710
+ acc.push(s.to_string());
711
+ } else if let Some(s) = item["question"].as_str() {
712
+ acc.push(s.to_string());
713
+ }
714
+ }
715
+ if acc.is_empty() {
716
+ input["question"].as_str().unwrap_or("").to_string()
717
+ } else {
718
+ acc.join("\n")
719
+ }
720
+ } else {
721
+ input["question"].as_str().unwrap_or("").to_string()
722
+ };
723
+ let question = question.trim();
724
+ if question.is_empty() {
725
+ return ("question is required".to_string(), true);
726
+ }
727
+
728
+ let mut options_str = String::new();
729
+ if let Some(opts) = input["options"].as_array() {
730
+ options_str.push_str("\nOptions:\n");
731
+ for (i, opt) in opts.iter().enumerate() {
732
+ if let Some(s) = opt.as_str() {
733
+ options_str.push_str(&format!(" {}. {}\n", i + 1, s));
734
+ } else {
735
+ let label = opt["label"].as_str().unwrap_or("");
736
+ let desc = opt["description"].as_str().unwrap_or("");
737
+ if !desc.is_empty() {
738
+ options_str.push_str(&format!(" {}. {} - {}\n", i + 1, label, desc));
739
+ } else {
740
+ options_str.push_str(&format!(" {}. {}\n", i + 1, label));
741
+ }
742
+ }
743
+ }
744
+ }
745
+
746
+ let full_prompt = format!("{}{}", question, options_str);
747
+ if report::is_json() || !std::io::stdin().is_terminal() {
748
+ return (
749
+ format!("blocked (no interactive terminal to answer question: {full_prompt})"),
750
+ true,
751
+ );
752
+ }
753
+ let default = input["default"].as_str().unwrap_or("").trim();
754
+ let prompt = if default.is_empty() {
755
+ format!(
756
+ " {} {}\n Answer: ",
757
+ tui::yellow("?"),
758
+ tui::bold(&full_prompt)
759
+ )
760
+ } else {
761
+ format!(
762
+ " {} {}\n Answer {} ",
763
+ tui::yellow("?"),
764
+ tui::bold(&full_prompt),
765
+ tui::dim(&format!("[{default}]"))
766
+ )
767
+ };
768
+ let ans = tui::ask(&prompt).unwrap_or_default();
769
+ let out = if ans.trim().is_empty() && !default.is_empty() {
770
+ default.to_string()
771
+ } else {
772
+ ans
773
+ };
774
+ (out, false)
775
+ }
776
+
128
777
  // ── permissions ───────────────────────────────────────────────────────────────
129
778
  #[derive(Clone, Copy)]
130
779
  pub enum Permission {
@@ -153,6 +802,12 @@ pub fn role(id: &str) -> Role {
153
802
  _ => "You are an autonomous senior software engineer. \
154
803
  Use the tools to inspect and modify the project directly. \
155
804
  Prefer small, verifiable edits. Read before you write. \
805
+ When writing or editing files, provide the complete, fully working code. NEVER use placeholders (e.g. `// ... rest of code`). \
806
+ BUILD mode means execute: for any concrete build/fix/create/change request, inspect the project and make the needed edits instead of replying with a capability statement. \
807
+ For simple browser games, canvas demos, landing pages, prototypes, and static visual apps, build an actual runnable artifact. Prefer a single self-contained HTML file via Artifact/publish_artifact unless the user explicitly asks for a framework. NEVER search GitHub, git clone external repositories, or look for existing games online when asked to build a game, app, or prototype from scratch. You MUST write the code yourself from scratch. When asked to build or create something, YOU MUST IMMEDIATELY CALL TOOLS to create the files on disk (e.g. write_file, edit_file, run_command). DO NOT output markdown instructions, step-by-step tutorials, or code blocks in chat telling the user how to build it! YOU are the builder — execute the tool calls to build it yourself directly. \
808
+ For local web apps that require a dev server, use start_server, wait_for_url, inspect read_server_log if readiness fails, then open_browser when useful. \
809
+ If a path or file is missing, use discovery tools before asking the user. \
810
+ DO NOT ask the user for permission, themes, or choices unless absolutely necessary. If the user leaves something open-ended (e.g. 'pick a theme' or 'make it cool'), MAKE A REASONABLE DECISION and proceed immediately. \
156
811
  When the task is complete, call the finish tool with a one-paragraph summary.\n\n\
157
812
  IMPORTANT — tool discipline:\n\
158
813
  • Before using run_command to install anything (npm install, pip install, cargo add, brew install, \
@@ -168,32 +823,130 @@ over installing an alternative.",
168
823
  }
169
824
 
170
825
  // Build the system prompt prefix from memory and skills/agents files.
171
- fn context_prefix() -> String {
826
+ // When context_tokens is small (≤16K), skip expensive sections to leave
827
+ // room for tool definitions + actual conversation.
828
+ fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
829
+ let compact = context_tokens <= 16_384;
172
830
  let mut parts: Vec<String> = Vec::new();
173
831
 
832
+ let home_str = std::env::var("HOME").unwrap_or_default();
833
+ let mut path_info = format!(
834
+ "[Environment Paths]\n\
835
+ Workspace (CWD): {}\n\
836
+ User Home (~): {}\n",
837
+ cwd.display(),
838
+ home_str
839
+ );
840
+ if cwd.to_string_lossy().contains("/tmp/") || cwd.to_string_lossy().contains("/private/tmp/") {
841
+ path_info.push_str(
842
+ "Note: The current workspace is running in a temporary directory/mount. \
843
+ To find personal folders or files belonging to the user, you must search starting from the User Home (~).\n"
844
+ );
845
+ }
846
+ parts.push(path_info);
847
+
174
848
  // Always include the tool manifest so the model knows what's built-in
175
849
  // and doesn't try to install external tools to do things we already handle.
176
850
  parts.push(tool_manifest());
851
+ if !compact {
852
+ parts.push(visible_reasoning_policy());
853
+ parts.push(discovery_policy());
854
+ parts.push(skill_manifest());
855
+ }
177
856
 
178
857
  // Probe which common system tools are actually present so the model can
179
858
  // pick the right one without guessing or installing alternatives.
180
- let env_snap = env_snapshot();
181
- if !env_snap.is_empty() {
182
- parts.push(format!("[Environment tools already installed]\n{env_snap}"));
859
+ // Skip for small contexts — the tool defs already dominate.
860
+ if !compact {
861
+ let env_snap = env_snapshot();
862
+ if !env_snap.is_empty() {
863
+ parts.push(format!(
864
+ "[Environment — tools already installed]\n{env_snap}"
865
+ ));
866
+ }
183
867
  }
184
868
 
185
869
  if let Some(mem) = config::load_memory() {
186
- parts.push(format!("[Memory from previous sessions]\n{mem}"));
870
+ // Memory is user-important; always include but truncate for small ctx
871
+ let mem_text = if compact && mem.len() > 300 {
872
+ format!("{}…", &mem[..300])
873
+ } else {
874
+ mem
875
+ };
876
+ parts.push(format!("[Memory from previous sessions]\n{mem_text}"));
187
877
  }
188
878
  if let Some(agents) = config::load_agents() {
189
- parts.push(format!("[Agent knowledge — Agents.md]\n{agents}"));
879
+ trace::record_visible(
880
+ "agents",
881
+ "loaded Agents.md",
882
+ serde_json::json!({"bytes": agents.len(), "preview": trace::preview(&agents, 600)}),
883
+ );
884
+ // For small contexts, truncate Agents.md to avoid blowing the budget
885
+ let agents_text = if compact && agents.len() > 500 {
886
+ format!("{}…", &agents[..500])
887
+ } else {
888
+ agents
889
+ };
890
+ parts.push(format!("[Agent knowledge — Agents.md]\n{agents_text}"));
190
891
  }
191
- let skills = config::load_skills();
192
- if !skills.is_empty() {
193
- let joined = skills.iter()
194
- .map(|(name, content)| format!("## Skill: {name}\n{content}"))
195
- .collect::<Vec<_>>().join("\n\n");
196
- parts.push(format!("[Available skills]\n{joined}"));
892
+
893
+ // Skip rules, knowledge, hooks, and skill descriptions for small contexts
894
+ if !compact {
895
+ // Inject active rules for operational judgment
896
+ let mut engine = crate::rules::RuleEngine::load_defaults();
897
+ let rules_dir = config::home().join("rules");
898
+ if let Ok(rd) = std::fs::read_dir(&rules_dir) {
899
+ for e in rd.flatten() {
900
+ if let Ok(loaded) =
901
+ crate::rules::RuleEngine::load_from_file(&e.path().to_string_lossy())
902
+ {
903
+ for r in loaded.rules {
904
+ engine.add_rule(r);
905
+ }
906
+ }
907
+ }
908
+ }
909
+ let mut rules_summary = String::from("The following engineering constraints and business logic rules MUST be strictly enforced for operational judgment:\n");
910
+ for r in &engine.rules {
911
+ if r.enabled {
912
+ rules_summary.push_str(&format!(
913
+ "• [{}] {} — {}\n",
914
+ r.severity, r.id, r.description
915
+ ));
916
+ }
917
+ }
918
+ parts.push(format!(
919
+ "[Operational Judgment — Engineering Constraints & Rules]\n{}",
920
+ rules_summary.trim()
921
+ ));
922
+
923
+ // Inject structured knowledge base if present
924
+ let kb = crate::knowledge::KnowledgeBase::new(".");
925
+ if !kb.entities.is_empty() {
926
+ parts.push(format!(
927
+ "[Structured Knowledge Base — Known Project Entities]\n{}",
928
+ kb.generate_context_summary(&[])
929
+ ));
930
+ }
931
+
932
+ let active_hooks = hooks::list_active();
933
+ if !active_hooks.is_empty() {
934
+ parts.push(format!("[Active Hooks]\n{}", active_hooks.join("\n")));
935
+ }
936
+ let skill_descs = config::load_skill_descriptions();
937
+ if !skill_descs.is_empty() {
938
+ let joined = skill_descs
939
+ .iter()
940
+ .map(|(name, desc)| format!("• /{name} — {desc}"))
941
+ .collect::<Vec<_>>()
942
+ .join("\n");
943
+ parts.push(format!(
944
+ "[Available skills — descriptions only]\n{joined}\n\n\
945
+ To use a skill, call load_skill with the skill name to load its full instructions. \
946
+ Only load skills that are directly relevant to the current task. \
947
+ Do not load skills for simple greetings or casual replies."
948
+ ));
949
+ }
197
950
  }
198
951
 
199
952
  format!("{}\n\n", parts.join("\n\n"))
@@ -201,16 +954,44 @@ fn context_prefix() -> String {
201
954
 
202
955
  fn tool_manifest() -> String {
203
956
  "[Built-in tools — always available, no install needed]\n\
204
- read_file / list_dir — read any file or directory on the filesystem; read_file supports start_line/end_line\n\
205
- find_files / grep_files search the codebase without shelling out; prefer these for navigation before run_command\n\
206
- write_file / edit_file create or surgically modify files\n\
207
- run_command — run any shell command: grep, find, git, cargo, make, npm, python3, etc.\n\
208
- fetch_url — HTTP GET (no curl/wget needed)\n\
209
- web_search — DuckDuckGo web search (no API key, live results)\n\
210
- save_memory — persist a note across sessions\n\
211
- spawn_subagent — delegate a sub-task to a fresh agent with its own context\n\
212
- • finish — mark the task complete with a summary"
213
- .to_string()
957
+ Aliases are supported: bash/read/write/edit/patch/glob/grep/list/task/todowrite/todoread/webfetch/websearch/skill/question. \
958
+ Use built-ins before installing anything. Use list_tree/find_paths/grep_files before guessing paths; use find_paths kind=`dir` for folders. \
959
+ Use start_server/list_servers/wait_for_url/read_server_log/stop_server for long-running local dev servers, and open_browser for local URLs or generated HTML.\n\n\
960
+ [CRITICAL TOOL DISCIPLINE]\n\
961
+ For generated/edited code, HTML, or file contents, call write_file/edit_file/Artifact; never paste code as plain markdown.\n\
962
+ For canvas games, browser games, standalone demos, landing pages, and small web apps: publish a complete runnable static HTML artifact with embedded CSS/JS unless a framework is explicitly requested. Include controls, restart/error states, responsive sizing, and touch/mobile support when useful; then open_browser if possible.\n\
963
+ When tasked to build, create, or write code/applications/games, build them locally from scratch. NEVER search the web or attempt to fetch non-existent repositories or URLs from GitHub or the internet unless the user explicitly provides a URL or asks to download from external sources.\n\
964
+ No placeholders. No asking for theme/layout/permission when a reasonable default works. Build immediately."
965
+ .to_string()
966
+ }
967
+
968
+ fn visible_reasoning_policy() -> String {
969
+ "[Visible reasoning]\n\
970
+ Emit short operational `<think>...</think>` notes before tool calls, recovery decisions, and final answers. Think briefly, then act."
971
+ .to_string()
972
+ }
973
+
974
+ fn discovery_policy() -> String {
975
+ "[Filesystem discovery policy]\n\
976
+ Do not invent placeholder paths. If a path is vague or a read/list fails, search with list_tree/find_paths/find_files/grep_files before asking. \
977
+ For folders use find_paths kind=`dir`. For user files/projects, search likely roots such as ~, ~/Documents, ~/Desktop, ~/Downloads, ~/Projects, ~/repos, then bounded parent directories. \
978
+ Only ask for a path after bounded discovery fails or the search would be too broad/sensitive."
979
+ .to_string()
980
+ }
981
+
982
+ fn skill_manifest() -> String {
983
+ let names = config::bundled_skills()
984
+ .into_iter()
985
+ .map(|(name, _)| format!("/{name}"))
986
+ .collect::<Vec<_>>()
987
+ .join(", ");
988
+ format!(
989
+ "[Skill invocation policy]\n\
990
+ Skills are first-class operating instructions. Before substantial work, choose and follow the most relevant skill from the loaded skills. \
991
+ If the user names a skill or uses a skill slash command, treat that skill as active context. \
992
+ Bundled skills are callable by users as slash commands: {names}. \
993
+ Use /trace to inspect evidence of skill loading, tool calls, hooks, and subagents."
994
+ )
214
995
  }
215
996
 
216
997
  fn env_snapshot() -> String {
@@ -218,23 +999,23 @@ fn env_snapshot() -> String {
218
999
  // We run everything in parallel-ish with short timeouts via sequential calls —
219
1000
  // the total overhead is ~10ms on a modern machine.
220
1001
  let probes: &[(&str, &str)] = &[
221
- ("node", "node --version"),
222
- ("npm", "npm --version"),
223
- ("npx", "npx --version"),
1002
+ ("node", "node --version"),
1003
+ ("npm", "npm --version"),
1004
+ ("npx", "npx --version"),
224
1005
  ("python3", "python3 --version"),
225
- ("pip3", "pip3 --version"),
226
- ("cargo", "cargo --version"),
227
- ("rustc", "rustc --version"),
228
- ("git", "git --version"),
229
- ("docker", "docker --version"),
230
- ("jq", "jq --version"),
231
- ("rg", "rg --version"),
232
- ("gh", "gh --version"),
233
- ("bun", "bun --version"),
234
- ("deno", "deno --version"),
235
- ("go", "go version"),
236
- ("ruby", "ruby --version"),
237
- ("java", "java --version"),
1006
+ ("pip3", "pip3 --version"),
1007
+ ("cargo", "cargo --version"),
1008
+ ("rustc", "rustc --version"),
1009
+ ("git", "git --version"),
1010
+ ("docker", "docker --version"),
1011
+ ("jq", "jq --version"),
1012
+ ("rg", "rg --version"),
1013
+ ("gh", "gh --version"),
1014
+ ("bun", "bun --version"),
1015
+ ("deno", "deno --version"),
1016
+ ("go", "go version"),
1017
+ ("ruby", "ruby --version"),
1018
+ ("java", "java --version"),
238
1019
  ];
239
1020
 
240
1021
  use std::process::Command;
@@ -243,13 +1024,18 @@ fn env_snapshot() -> String {
243
1024
  let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
244
1025
  let bin = parts[0];
245
1026
  let arg = parts.get(1).copied().unwrap_or("--version");
246
- let ok = Command::new(bin).arg(arg)
1027
+ let ok = Command::new(bin)
1028
+ .arg(arg)
247
1029
  .stdout(std::process::Stdio::piped())
248
1030
  .stderr(std::process::Stdio::piped())
249
1031
  .output()
250
1032
  .map(|o| {
251
1033
  let txt = String::from_utf8_lossy(&o.stdout).trim().to_string();
252
- let txt = if txt.is_empty() { String::from_utf8_lossy(&o.stderr).trim().to_string() } else { txt };
1034
+ let txt = if txt.is_empty() {
1035
+ String::from_utf8_lossy(&o.stderr).trim().to_string()
1036
+ } else {
1037
+ txt
1038
+ };
253
1039
  txt.lines().next().unwrap_or("").to_string()
254
1040
  })
255
1041
  .ok()
@@ -261,14 +1047,78 @@ fn env_snapshot() -> String {
261
1047
  found.join("\n")
262
1048
  }
263
1049
 
1050
+ static SESSION_ALLOWED_TOOLS: Mutex<Option<HashSet<String>>> = Mutex::new(None);
1051
+
1052
+ fn is_session_allowed_tool(key: &str) -> bool {
1053
+ if key.is_empty() {
1054
+ return false;
1055
+ }
1056
+ if let Ok(guard) = SESSION_ALLOWED_TOOLS.lock() {
1057
+ if let Some(set) = guard.as_ref() {
1058
+ return set.contains(key);
1059
+ }
1060
+ }
1061
+ false
1062
+ }
1063
+
1064
+ fn add_session_allowed_tool(key: &str) {
1065
+ if key.is_empty() {
1066
+ return;
1067
+ }
1068
+ if let Ok(mut guard) = SESSION_ALLOWED_TOOLS.lock() {
1069
+ let set = guard.get_or_insert_with(HashSet::new);
1070
+ set.insert(key.to_string());
1071
+ }
1072
+ }
1073
+
1074
+ #[allow(dead_code)]
264
1075
  fn confirm(label: &str) -> Option<String> {
1076
+ confirm_tool(label, "")
1077
+ }
1078
+
1079
+ fn confirm_tool(label: &str, tool_key: &str) -> Option<String> {
265
1080
  if report::is_json() || !std::io::stdin().is_terminal() {
266
- return Some(format!("blocked (no interactive terminal to confirm: {label})"));
1081
+ return Some(format!(
1082
+ "blocked (no interactive terminal to confirm: {label})"
1083
+ ));
267
1084
  }
268
- let q = format!(" {} {}? {} ", tui::yellow("➤"), tui::bold(label), tui::dim("[y/N]"));
1085
+ let q = format!(
1086
+ " {} {}? {} ",
1087
+ tui::yellow("➤"),
1088
+ tui::bold(label),
1089
+ tui::dim("[y: yes | n: no | s: allow session | a: allow always | d <reason>: deny with feedback]")
1090
+ );
269
1091
  let ans = tui::ask(&q).unwrap_or_default();
270
- if matches!(ans.trim().to_lowercase().as_str(), "y" | "yes") {
1092
+ let trimmed = ans.trim();
1093
+ let lower = trimmed.to_lowercase();
1094
+ if matches!(lower.as_str(), "y" | "yes") {
271
1095
  None
1096
+ } else if matches!(lower.as_str(), "s" | "session") {
1097
+ add_session_allowed_tool(tool_key);
1098
+ None
1099
+ } else if matches!(lower.as_str(), "a" | "always") {
1100
+ add_session_allowed_tool(tool_key);
1101
+ if !tool_key.is_empty() {
1102
+ if let Some(mut s) = crate::config::load_settings() {
1103
+ if !s.allowed_commands.contains(&tool_key.to_string()) {
1104
+ s.allowed_commands.push(tool_key.to_string());
1105
+ crate::config::save_settings(&s);
1106
+ }
1107
+ }
1108
+ }
1109
+ None
1110
+ } else if lower.starts_with("d ")
1111
+ || lower.starts_with("deny ")
1112
+ || lower.starts_with("n ")
1113
+ || lower.starts_with("no ")
1114
+ {
1115
+ let idx = trimmed.find(' ').unwrap_or(0);
1116
+ let feedback = trimmed[idx..].trim();
1117
+ if feedback.is_empty() {
1118
+ Some("denied by user".into())
1119
+ } else {
1120
+ Some(format!("denied by user with feedback: {feedback}"))
1121
+ }
272
1122
  } else {
273
1123
  Some("denied by user".into())
274
1124
  }
@@ -277,43 +1127,64 @@ fn confirm(label: &str) -> Option<String> {
277
1127
  // Permission gate — returns Some(reason) when blocked.
278
1128
  // Read operations outside CWD are allowed in all modes (no CWD confinement for
279
1129
  // reads); the user specifically asked for full filesystem access.
280
- pub(crate) fn gate(perm: Permission, name: &str, input: &serde_json::Value, cwd: &Path) -> Option<String> {
1130
+ pub(crate) fn gate(
1131
+ perm: Permission,
1132
+ name: &str,
1133
+ input: &serde_json::Value,
1134
+ cwd: &Path,
1135
+ ) -> Option<String> {
1136
+ let tool_key = if let Some(c) = tools::command_arg_for(name, input) {
1137
+ let first = c.split_whitespace().next().unwrap_or("");
1138
+ std::path::Path::new(first)
1139
+ .file_name()
1140
+ .and_then(|n| n.to_str())
1141
+ .unwrap_or(first)
1142
+ .to_string()
1143
+ } else {
1144
+ name.to_string()
1145
+ };
1146
+
281
1147
  let path = tools::touched_path(name, input, cwd);
282
1148
 
283
1149
  if let Some(p) = &path {
284
1150
  if tools::is_sensitive(p) {
285
- return confirm(&format!("access sensitive path {}", p.display()));
1151
+ return confirm_tool(&format!("access sensitive path {}", p.display()), &tool_key);
286
1152
  }
287
1153
  // In WSL2, writing to a Windows drive mount (/mnt/c/, /mnt/d/, etc.)
288
1154
  // crosses the OS boundary — always confirm, even in Auto mode.
289
- if tools::is_mutating(name) && tools::is_wsl_windows_mount(p) {
290
- return confirm(&format!("write to Windows filesystem {} (WSL2 boundary)", p.display()));
1155
+ if tools::is_mutating_call(name, input) && tools::is_wsl_windows_mount(p) {
1156
+ return confirm_tool(
1157
+ &format!(
1158
+ "write to Windows filesystem {} (WSL2 boundary)",
1159
+ p.display()
1160
+ ),
1161
+ &tool_key,
1162
+ );
291
1163
  }
292
1164
  }
293
- if name == "run_command" {
294
- if let Some(c) = input["command"].as_str() {
295
- if tools::catastrophic(c) {
296
- return confirm(&format!("run dangerous command `{c}`"));
297
- }
298
- // In WSL2, commands that reference /mnt/<drive>/ target the Windows
299
- // filesystem confirm before running, even in Auto mode.
300
- if tools::is_wsl() && tools::command_touches_wsl_mount(c) {
301
- return confirm(&format!("command targets Windows filesystem (WSL2): `{c}`"));
302
- }
1165
+ if let Some(c) = tools::command_arg_for(name, input) {
1166
+ if tools::catastrophic(c) {
1167
+ return confirm_tool(&format!("run dangerous command `{c}`"), &tool_key);
1168
+ }
1169
+ // In WSL2, commands that reference /mnt/<drive>/ target the Windows
1170
+ // filesystem confirm before running, even in Auto mode.
1171
+ if tools::is_wsl() && tools::command_touches_wsl_mount(c) {
1172
+ return confirm_tool(
1173
+ &format!("command targets Windows filesystem (WSL2): `{c}`"),
1174
+ &tool_key,
1175
+ );
303
1176
  }
304
1177
  }
305
1178
 
306
1179
  match perm {
307
1180
  Permission::Auto => None,
308
1181
  Permission::ReadOnly => {
309
- if tools::is_mutating(name) {
1182
+ if tools::is_mutating_call(name, input) {
310
1183
  // run_command with clearly read-only shell tools (grep, find, etc.)
311
1184
  // should pass through even in ReadOnly mode.
312
- if name == "run_command" {
313
- if let Some(c) = input["command"].as_str() {
314
- if tools::is_readonly_command(c) {
315
- return None;
316
- }
1185
+ if let Some(c) = tools::command_arg_for(name, input) {
1186
+ if tools::is_readonly_command(c) {
1187
+ return None;
317
1188
  }
318
1189
  }
319
1190
  return Some("read-only mode: mutation skipped".into());
@@ -323,19 +1194,15 @@ pub(crate) fn gate(perm: Permission, name: &str, input: &serde_json::Value, cwd:
323
1194
  Permission::Ask => {
324
1195
  // run_command calls whose binary appears in allowed_commands skip the
325
1196
  // confirmation prompt — git, cargo, npm, etc. should just work.
326
- if name == "run_command" {
327
- if let Some(c) = input["command"].as_str() {
328
- let first = c.split_whitespace().next().unwrap_or("");
329
- // Accept both bare names and full paths (/usr/bin/git → git).
330
- let bin = std::path::Path::new(first)
331
- .file_name().and_then(|n| n.to_str()).unwrap_or(first);
332
- if config::load_allowed_commands().iter().any(|a| a == bin) {
333
- return None;
334
- }
335
- }
1197
+ if config::load_allowed_commands()
1198
+ .iter()
1199
+ .any(|a| a == &tool_key)
1200
+ || is_session_allowed_tool(&tool_key)
1201
+ {
1202
+ return None;
336
1203
  }
337
- if tools::is_mutating(name) {
338
- return confirm(&tools::preview(name, input));
1204
+ if tools::is_mutating_call(name, input) {
1205
+ return confirm_tool(&tools::preview(name, input), &tool_key);
339
1206
  }
340
1207
  // Out-of-cwd reads: just note it instead of hard-blocking.
341
1208
  // The user asked for full filesystem access.
@@ -350,23 +1217,63 @@ pub fn compact_msgs(p: &Provider, msgs: Vec<Msg>) -> Vec<Msg> {
350
1217
  }
351
1218
 
352
1219
  // ── BUILD mode ────────────────────────────────────────────────────────────────
353
- pub fn run_build(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path) -> Result<(), String> {
1220
+ pub fn run_build(
1221
+ p: &Provider,
1222
+ perm: Permission,
1223
+ role_id: &str,
1224
+ task: &str,
1225
+ cwd: &Path,
1226
+ ) -> Result<(), String> {
354
1227
  let mut transcript: Vec<Msg> = Vec::new();
355
- run_build_session(p, perm, role_id, task, cwd, &mut transcript, &crate::session::new_id())
1228
+ run_build_session(
1229
+ p,
1230
+ perm,
1231
+ role_id,
1232
+ task,
1233
+ cwd,
1234
+ &mut transcript,
1235
+ &crate::session::new_id(),
1236
+ )
356
1237
  }
357
1238
 
358
- pub fn run_build_resumed(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path, mut seed: Vec<Msg>, sid: &str) -> Result<(), String> {
1239
+ pub fn run_build_resumed(
1240
+ p: &Provider,
1241
+ perm: Permission,
1242
+ role_id: &str,
1243
+ task: &str,
1244
+ cwd: &Path,
1245
+ mut seed: Vec<Msg>,
1246
+ sid: &str,
1247
+ ) -> Result<(), String> {
359
1248
  run_build_session(p, perm, role_id, task, cwd, &mut seed, sid)
360
1249
  }
361
1250
 
362
- pub fn run_build_session(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path, transcript: &mut Vec<Msg>, sid: &str) -> Result<(), String> {
1251
+ pub fn run_build_session(
1252
+ p: &Provider,
1253
+ perm: Permission,
1254
+ role_id: &str,
1255
+ task: &str,
1256
+ cwd: &Path,
1257
+ transcript: &mut Vec<Msg>,
1258
+ sid: &str,
1259
+ ) -> Result<(), String> {
1260
+ hooks::notify("SessionStart", cwd);
363
1261
  let r = build_inner(p, perm, role_id, task, cwd, 0, transcript).map(|_| ());
1262
+ hooks::notify("SessionEnd", cwd);
364
1263
  hooks::notify("Stop", cwd);
365
1264
  crate::session::save(sid, cwd, &p.model, transcript);
366
1265
  r
367
1266
  }
368
1267
 
369
- fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path, depth: usize, msgs: &mut Vec<Msg>) -> Result<String, String> {
1268
+ fn build_inner(
1269
+ p: &Provider,
1270
+ perm: Permission,
1271
+ role_id: &str,
1272
+ task: &str,
1273
+ cwd: &Path,
1274
+ depth: usize,
1275
+ msgs: &mut Vec<Msg>,
1276
+ ) -> Result<String, String> {
370
1277
  let task = match hooks::user_prompt_submit(task, cwd) {
371
1278
  Err(reason) => {
372
1279
  report::error(&format!("blocked by hook: {reason}"));
@@ -375,9 +1282,14 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
375
1282
  Ok(ctx) if !ctx.is_empty() => format!("{task}\n\n[hook context]\n{ctx}"),
376
1283
  Ok(_) => task.to_string(),
377
1284
  };
378
- let defs = tools::defs(depth < MAX_DEPTH);
1285
+ let task_for_recovery = recovery_task_text(&task);
1286
+ let defs = if matches!(perm, Permission::ReadOnly) {
1287
+ tools::defs_readonly()
1288
+ } else {
1289
+ tools::defs_for_context(depth < MAX_DEPTH, p.context_tokens)
1290
+ };
379
1291
  if msgs.is_empty() {
380
- let prefix = context_prefix();
1292
+ let prefix = context_prefix(cwd, p.context_tokens);
381
1293
  let sys = format!("{prefix}{}", role(role_id).system);
382
1294
  msgs.push(Msg::System(sys));
383
1295
  }
@@ -385,11 +1297,14 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
385
1297
  // text as the task without pushing another User turn; otherwise push normally.
386
1298
  let already_pushed = matches!(msgs.last(), Some(Msg::UserImages { .. }));
387
1299
  if !already_pushed {
388
- msgs.push(Msg::User(task));
1300
+ msgs.push(Msg::User(task.clone()));
389
1301
  }
390
1302
 
391
1303
  // Track which files have been read this session so we can enforce read-before-write.
392
1304
  let mut read_paths: std::collections::HashSet<PathBuf> = Default::default();
1305
+ let mut loop_guard = ToolLoopGuard::default();
1306
+ let mut artifact_error_count = 0usize;
1307
+ let mut static_artifact_recovery_count = 0usize;
393
1308
 
394
1309
  for step in 1..=MAX_ITERS {
395
1310
  if tui::interrupted() {
@@ -397,51 +1312,144 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
397
1312
  return Ok(String::new());
398
1313
  }
399
1314
  maybe_compact(p, msgs);
400
- let reply: Reply = if report::is_json() {
401
- let r = complete(p, msgs.as_slice(), &defs)?;
402
- report::assistant(&r.text);
403
- r
404
- } else {
405
- // Show a step counter so the user can see multi-step reasoning progress.
406
- if step > 1 {
407
- tui::line(&tui::dim(&format!(" step {step}")));
408
- }
409
- let mut spin = Some(tui::spinner_start("thinking…"));
410
- let mut streamed = false;
411
- let mut thinking_buf = String::new();
412
- let mut renderer = tui::StreamRenderer::new();
413
- let res = provider::stream(p, msgs.as_slice(), &defs, &mut |c| {
414
- if let Some(s) = spin.take() {
415
- tui::spinner_stop(s);
1315
+ if step > 1 && !report::is_json() {
1316
+ tui::line(&tui::dim(&format!(" ↻ step {step}")));
1317
+ }
1318
+ let start_instant = std::time::Instant::now();
1319
+ let reply = match request_reply(p, msgs.as_slice(), &defs, "thinking") {
1320
+ Ok(r) => r,
1321
+ Err(e) => {
1322
+ hooks::notify("OnError", cwd);
1323
+ return Err(e);
1324
+ }
1325
+ };
1326
+ let reply = normalize_text_tool_calls(reply, &defs, &task_for_recovery);
1327
+ let elapsed = start_instant.elapsed().as_secs_f64();
1328
+ let gen_toks = (reply.text.len() / 4) + (reply.calls.len() * 40);
1329
+ if !report::is_json() {
1330
+ tui::inference_telemetry(gen_toks.max(10), elapsed);
1331
+ }
1332
+ hooks::notify("PostResponse", cwd);
1333
+ if reply.calls.is_empty() {
1334
+ if let Some(input) = auto_create_file_input(&task_for_recovery) {
1335
+ if let Some(reason) = gate(perm, "write_file", &input, cwd) {
1336
+ report::tool_denied(&reason);
1337
+ return Err(reason);
416
1338
  }
417
- renderer.push(c);
418
- streamed = true;
419
- }, &mut |t| {
420
- // Extended thinking: buffer and display as dim internal monologue.
421
- thinking_buf.push_str(t);
422
- // Flush complete sentences/lines so output feels live.
423
- while let Some(nl) = thinking_buf.find('\n') {
424
- let line = thinking_buf[..nl].trim().to_string();
425
- if !line.is_empty() {
426
- tui::write_stream(&format!("\r {} {}\r\n", tui::dim("◌"), tui::dim(&line)));
1339
+ report::tool_call("write_file", &tools::preview("write_file", &input), &input);
1340
+ trace_tool_call("write_file", &input, "build", depth);
1341
+ let out = tools::run("write_file", &input, cwd);
1342
+ hooks::post_tool_use("write_file", &input, &out.content, out.is_error, cwd);
1343
+ report::tool_result("write_file", &out.content, out.is_error);
1344
+ trace_tool_result("write_file", &out.content, out.is_error, "build", depth);
1345
+ if out.is_error {
1346
+ return Err(out.content);
1347
+ }
1348
+ return Ok(format!(
1349
+ "{}\n\nCompleted the explicit file creation task after the model returned prose without tool calls.",
1350
+ out.content
1351
+ ));
1352
+ }
1353
+ if let Some(input) = auto_static_artifact_input(&task_for_recovery, &reply.text) {
1354
+ report::tool_call("Artifact", &tools::preview("Artifact", &input), &input);
1355
+ trace_tool_call("Artifact", &input, "build", depth);
1356
+ let out = tools::run("Artifact", &input, cwd);
1357
+ report::tool_result("Artifact", &out.content, out.is_error);
1358
+ trace_tool_result("Artifact", &out.content, out.is_error, "build", depth);
1359
+ if out.is_error {
1360
+ if canvas_game_requested(&task_for_recovery) {
1361
+ let fallback = fallback_canvas_game_input(&task_for_recovery);
1362
+ report::tool_call(
1363
+ "Artifact",
1364
+ &tools::preview("Artifact", &fallback),
1365
+ &fallback,
1366
+ );
1367
+ trace_tool_call("Artifact", &fallback, "build", depth);
1368
+ let fallback_out = tools::run("Artifact", &fallback, cwd);
1369
+ report::tool_result(
1370
+ "Artifact",
1371
+ &fallback_out.content,
1372
+ fallback_out.is_error,
1373
+ );
1374
+ trace_tool_result(
1375
+ "Artifact",
1376
+ &fallback_out.content,
1377
+ fallback_out.is_error,
1378
+ "build",
1379
+ depth,
1380
+ );
1381
+ if !fallback_out.is_error {
1382
+ return Ok(format!(
1383
+ "{}\n\nThe model produced incomplete canvas HTML, so buildwithnexus published a complete self-contained fallback canvas game.",
1384
+ fallback_out.content
1385
+ ));
1386
+ }
1387
+ return Err(fallback_out.content);
427
1388
  }
428
- thinking_buf = thinking_buf[nl + 1..].to_string();
1389
+ msgs.push(Msg::Assistant {
1390
+ text: reply.text.clone(),
1391
+ calls: vec![],
1392
+ });
1393
+ msgs.push(Msg::User(format!(
1394
+ "The HTML artifact you wrote in plain text was rejected: {}. \
1395
+ Rewrite it as one complete self-contained HTML artifact and call Artifact/publish_artifact. \
1396
+ Do not answer with markdown code.",
1397
+ out.content
1398
+ )));
1399
+ continue;
429
1400
  }
430
- });
431
- if let Some(s) = spin.take() {
432
- tui::spinner_stop(s);
1401
+ msgs.push(Msg::Assistant {
1402
+ text: reply.text.clone(),
1403
+ calls: vec![],
1404
+ });
1405
+ return Ok(out.content);
433
1406
  }
434
- let r = res?;
435
- renderer.flush();
436
- if streamed {
437
- report::assistant_end();
1407
+ if static_artifact_requested(&task_for_recovery) {
1408
+ msgs.push(Msg::Assistant {
1409
+ text: reply.text.clone(),
1410
+ calls: vec![],
1411
+ });
1412
+ if static_artifact_recovery_count == 0 {
1413
+ static_artifact_recovery_count += 1;
1414
+ msgs.push(Msg::User(static_artifact_recovery_prompt(
1415
+ &task_for_recovery,
1416
+ )));
1417
+ continue;
1418
+ }
1419
+ if canvas_game_requested(&task_for_recovery) {
1420
+ let fallback = fallback_canvas_game_input(&task_for_recovery);
1421
+ report::tool_call(
1422
+ "Artifact",
1423
+ &tools::preview("Artifact", &fallback),
1424
+ &fallback,
1425
+ );
1426
+ trace_tool_call("Artifact", &fallback, "build", depth);
1427
+ let fallback_out = tools::run("Artifact", &fallback, cwd);
1428
+ report::tool_result("Artifact", &fallback_out.content, fallback_out.is_error);
1429
+ trace_tool_result(
1430
+ "Artifact",
1431
+ &fallback_out.content,
1432
+ fallback_out.is_error,
1433
+ "build",
1434
+ depth,
1435
+ );
1436
+ if !fallback_out.is_error {
1437
+ return Ok(format!(
1438
+ "{}\n\nThe model did not produce a runnable canvas artifact after being asked to proceed with reasonable defaults, so buildwithnexus published a complete self-contained fallback canvas game.",
1439
+ fallback_out.content
1440
+ ));
1441
+ }
1442
+ return Err(fallback_out.content);
1443
+ }
1444
+ return Ok(reply.text);
438
1445
  }
439
- r
440
- };
441
- if reply.calls.is_empty() {
442
1446
  if reply.text.trim().is_empty() {
443
1447
  report::notice("model returned no output");
444
1448
  }
1449
+ msgs.push(Msg::Assistant {
1450
+ text: reply.text.clone(),
1451
+ calls: vec![],
1452
+ });
445
1453
  return Ok(reply.text);
446
1454
  }
447
1455
 
@@ -449,103 +1457,343 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
449
1457
  let mut summary: Option<String> = None;
450
1458
  for call in &reply.calls {
451
1459
  if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
452
- let msg = format!("tool arguments were not valid JSON: {}", raw.chars().take(200).collect::<String>());
1460
+ let msg = format!(
1461
+ "tool arguments were not valid JSON: {}",
1462
+ raw.chars().take(200).collect::<String>()
1463
+ );
453
1464
  report::tool_denied(&msg);
454
- results.push(ToolResult { id: call.id.clone(), content: msg, is_error: true });
1465
+ note_loop_result(
1466
+ &mut loop_guard,
1467
+ &call.name,
1468
+ &call.input,
1469
+ &msg,
1470
+ true,
1471
+ &mut summary,
1472
+ );
1473
+ results.push(ToolResult {
1474
+ id: call.id.clone(),
1475
+ content: msg,
1476
+ is_error: true,
1477
+ });
1478
+ continue;
1479
+ }
1480
+ let call_input = tool_input_for_execution(
1481
+ &call.name,
1482
+ &call.input,
1483
+ cwd,
1484
+ "build",
1485
+ depth,
1486
+ &task_for_recovery,
1487
+ );
1488
+ if matches!(call.name.as_str(), "bash" | "run_command") {
1489
+ if let Some(fallback) = auto_create_file_input(&task_for_recovery) {
1490
+ report::notice(
1491
+ " recovery: using write_file for explicit file creation instead of shell",
1492
+ );
1493
+ if let Some(reason) = gate(perm, "write_file", &fallback, cwd) {
1494
+ report::tool_denied(&reason);
1495
+ results.push(ToolResult {
1496
+ id: call.id.clone(),
1497
+ content: reason,
1498
+ is_error: true,
1499
+ });
1500
+ continue;
1501
+ }
1502
+ report::tool_call(
1503
+ "write_file",
1504
+ &tools::preview("write_file", &fallback),
1505
+ &fallback,
1506
+ );
1507
+ trace_tool_call("write_file", &fallback, "build", depth);
1508
+ let fallback_out = tools::run("write_file", &fallback, cwd);
1509
+ hooks::post_tool_use(
1510
+ "write_file",
1511
+ &fallback,
1512
+ &fallback_out.content,
1513
+ fallback_out.is_error,
1514
+ cwd,
1515
+ );
1516
+ report::tool_result("write_file", &fallback_out.content, fallback_out.is_error);
1517
+ trace_tool_result(
1518
+ "write_file",
1519
+ &fallback_out.content,
1520
+ fallback_out.is_error,
1521
+ "build",
1522
+ depth,
1523
+ );
1524
+ results.push(ToolResult {
1525
+ id: call.id.clone(),
1526
+ content: fallback_out.content.clone(),
1527
+ is_error: fallback_out.is_error,
1528
+ });
1529
+ if !fallback_out.is_error {
1530
+ summary = Some(format!(
1531
+ "{}\n\nCompleted the explicit file creation task with write_file.",
1532
+ fallback_out.content
1533
+ ));
1534
+ }
1535
+ continue;
1536
+ }
1537
+ }
1538
+ report::tool_call(
1539
+ &call.name,
1540
+ &tools::preview(&call.name, &call_input),
1541
+ &call_input,
1542
+ );
1543
+ trace_tool_call(&call.name, &call_input, "build", depth);
1544
+
1545
+ if call.name == "question" || call.name == "AskUserQuestion" {
1546
+ let (answer, is_error) = answer_question(&call_input);
1547
+ report::tool_result(&call.name, &answer, is_error);
1548
+ trace_tool_result(&call.name, &answer, is_error, "build", depth);
1549
+ note_loop_result(
1550
+ &mut loop_guard,
1551
+ &call.name,
1552
+ &call_input,
1553
+ &answer,
1554
+ is_error,
1555
+ &mut summary,
1556
+ );
1557
+ results.push(ToolResult {
1558
+ id: call.id.clone(),
1559
+ content: answer,
1560
+ is_error,
1561
+ });
455
1562
  continue;
456
1563
  }
457
- report::tool_call(&call.name, &tools::preview(&call.name, &call.input), &call.input);
458
1564
 
459
- let reason = match hooks::pre_tool_use(&call.name, &call.input, cwd) {
1565
+ let reason = match hooks::pre_tool_use(&call.name, &call_input, cwd) {
460
1566
  PreDecision::Deny(r) => Some(r),
461
1567
  PreDecision::Allow => None,
462
- PreDecision::Continue => gate(perm, &call.name, &call.input, cwd),
1568
+ PreDecision::Continue => gate(perm, &call.name, &call_input, cwd),
463
1569
  };
464
1570
  if let Some(reason) = reason {
465
1571
  report::tool_denied(&reason);
466
- results.push(ToolResult { id: call.id.clone(), content: reason, is_error: true });
1572
+ trace::record_visible(
1573
+ "tool_denied",
1574
+ format!("{} denied", call.name),
1575
+ serde_json::json!({"tool": call.name, "reason": reason, "input": &call_input, "phase": "build", "depth": depth}),
1576
+ );
1577
+ note_loop_result(
1578
+ &mut loop_guard,
1579
+ &call.name,
1580
+ &call_input,
1581
+ &reason,
1582
+ true,
1583
+ &mut summary,
1584
+ );
1585
+ results.push(ToolResult {
1586
+ id: call.id.clone(),
1587
+ content: reason,
1588
+ is_error: true,
1589
+ });
467
1590
  continue;
468
1591
  }
469
1592
 
470
1593
  // Record reads so we can enforce read-before-write below.
471
- if call.name == "read_file" {
472
- if let Some(raw) = call.input["path"].as_str() {
473
- let p = if std::path::Path::new(raw).is_absolute() {
474
- PathBuf::from(raw)
475
- } else {
476
- cwd.join(raw)
477
- };
478
- read_paths.insert(p);
479
- }
1594
+ if let Some(p) = tools::read_tracking_path(&call.name, &call_input, cwd) {
1595
+ read_paths.insert(p);
480
1596
  }
481
1597
 
482
1598
  // Require the model to read a file before overwriting or patching it.
483
- if matches!(call.name.as_str(), "write_file" | "edit_file") {
484
- if let Some(raw) = call.input["path"].as_str() {
485
- let p = if std::path::Path::new(raw).is_absolute() {
486
- PathBuf::from(raw)
487
- } else {
488
- cwd.join(raw)
489
- };
490
- if p.exists() && !read_paths.contains(&p) {
491
- let msg = format!(
492
- "read_file('{}') required before editing. Read the file first, then retry.",
493
- p.display()
494
- );
495
- report::tool_denied(&msg);
496
- results.push(ToolResult { id: call.id.clone(), content: msg, is_error: true });
497
- continue;
498
- }
1599
+ if let Some(p) = tools::edit_tracking_path(&call.name, &call_input, cwd) {
1600
+ if p.exists() && !read_paths.contains(&p) {
1601
+ let msg = format!(
1602
+ "read('{}') required before editing. Read the file first, then retry.",
1603
+ p.display()
1604
+ );
1605
+ report::tool_denied(&msg);
1606
+ note_loop_result(
1607
+ &mut loop_guard,
1608
+ &call.name,
1609
+ &call_input,
1610
+ &msg,
1611
+ true,
1612
+ &mut summary,
1613
+ );
1614
+ results.push(ToolResult {
1615
+ id: call.id.clone(),
1616
+ content: msg,
1617
+ is_error: true,
1618
+ });
1619
+ continue;
499
1620
  }
500
1621
  }
501
1622
 
502
- if call.name == "spawn_subagent" {
503
- let out = spawn_subagent(p, perm, &call.input, cwd, depth);
1623
+ if matches!(call.name.as_str(), "task" | "spawn_subagent") {
1624
+ let out = spawn_subagent(p, perm, &call_input, cwd, depth);
504
1625
  report::tool_result(&call.name, &out, false);
505
- results.push(ToolResult { id: call.id.clone(), content: out, is_error: false });
1626
+ trace_tool_result(&call.name, &out, false, "build", depth);
1627
+ results.push(ToolResult {
1628
+ id: call.id.clone(),
1629
+ content: out,
1630
+ is_error: false,
1631
+ });
506
1632
  continue;
507
1633
  }
508
1634
  // Memory-save tool: the model can call save_memory to persist facts.
509
1635
  if call.name == "save_memory" {
510
- if let Some(note) = call.input["note"].as_str() {
1636
+ if let Some(note) = call_input["note"].as_str() {
511
1637
  config::append_memory(note);
512
1638
  let msg = "memory saved".to_string();
513
1639
  report::tool_result(&call.name, &msg, false);
514
- results.push(ToolResult { id: call.id.clone(), content: msg, is_error: false });
1640
+ trace_tool_result(&call.name, &msg, false, "build", depth);
1641
+ results.push(ToolResult {
1642
+ id: call.id.clone(),
1643
+ content: msg,
1644
+ is_error: false,
1645
+ });
515
1646
  continue;
516
1647
  }
517
1648
  }
518
1649
 
519
- let out = tools::run(&call.name, &call.input, cwd);
520
- hooks::post_tool_use(&call.name, &call.input, &out.content, out.is_error, cwd);
1650
+ let out = tools::run(&call.name, &call_input, cwd);
1651
+ hooks::post_tool_use(&call.name, &call_input, &out.content, out.is_error, cwd);
1652
+ if out.is_error {
1653
+ hooks::notify("OnError", cwd);
1654
+ }
521
1655
  report::tool_result(&call.name, &out.content, out.is_error);
1656
+ trace_tool_result(&call.name, &out.content, out.is_error, "build", depth);
1657
+ if matches!(call.name.as_str(), "bash" | "run_command")
1658
+ && out.is_error
1659
+ && out.content.contains("No such file or directory")
1660
+ {
1661
+ if let Some(fallback) = auto_create_file_input(&task_for_recovery) {
1662
+ report::tool_call(
1663
+ "write_file",
1664
+ &tools::preview("write_file", &fallback),
1665
+ &fallback,
1666
+ );
1667
+ trace_tool_call("write_file", &fallback, "build", depth);
1668
+ let fallback_out = tools::run("write_file", &fallback, cwd);
1669
+ report::tool_result("write_file", &fallback_out.content, fallback_out.is_error);
1670
+ trace_tool_result(
1671
+ "write_file",
1672
+ &fallback_out.content,
1673
+ fallback_out.is_error,
1674
+ "build",
1675
+ depth,
1676
+ );
1677
+ if !fallback_out.is_error {
1678
+ summary = Some(format!(
1679
+ "{}\n\nRecovered from a failed shell redirect by writing the requested file directly.",
1680
+ fallback_out.content
1681
+ ));
1682
+ }
1683
+ }
1684
+ }
1685
+ if matches!(call.name.as_str(), "Artifact" | "publish_artifact")
1686
+ && out.is_error
1687
+ && canvas_game_requested(&task_for_recovery)
1688
+ {
1689
+ artifact_error_count += 1;
1690
+ if artifact_error_count >= 1 {
1691
+ let fallback = fallback_canvas_game_input(&task_for_recovery);
1692
+ report::tool_call(
1693
+ "Artifact",
1694
+ &tools::preview("Artifact", &fallback),
1695
+ &fallback,
1696
+ );
1697
+ trace_tool_call("Artifact", &fallback, "build", depth);
1698
+ let fallback_out = tools::run("Artifact", &fallback, cwd);
1699
+ report::tool_result("Artifact", &fallback_out.content, fallback_out.is_error);
1700
+ trace_tool_result(
1701
+ "Artifact",
1702
+ &fallback_out.content,
1703
+ fallback_out.is_error,
1704
+ "build",
1705
+ depth,
1706
+ );
1707
+ if !fallback_out.is_error {
1708
+ summary = Some(format!(
1709
+ "{}\n\nThe model repeatedly produced incomplete canvas HTML, so buildwithnexus published a complete self-contained fallback canvas game.",
1710
+ fallback_out.content
1711
+ ));
1712
+ }
1713
+ }
1714
+ }
522
1715
  if out.finished {
523
1716
  report::finish(&out.content);
524
1717
  summary = Some(out.content.clone());
1718
+ } else {
1719
+ note_loop_result(
1720
+ &mut loop_guard,
1721
+ &call.name,
1722
+ &call_input,
1723
+ &out.content,
1724
+ out.is_error,
1725
+ &mut summary,
1726
+ );
525
1727
  }
526
- results.push(ToolResult { id: call.id.clone(), content: out.content, is_error: out.is_error });
1728
+ results.push(ToolResult {
1729
+ id: call.id.clone(),
1730
+ content: out.content,
1731
+ is_error: out.is_error,
1732
+ });
527
1733
  }
528
1734
 
529
- msgs.push(Msg::Assistant { text: reply.text, calls: reply.calls });
1735
+ msgs.push(Msg::Assistant {
1736
+ text: assistant_tool_turn_text(reply.text, &reply.calls),
1737
+ calls: reply.calls,
1738
+ });
530
1739
  msgs.push(Msg::Tool(results));
531
1740
  if !report::is_json() {
532
1741
  tui::context_meter(estimate_tokens(msgs), p.context_tokens);
533
1742
  tui::poll_typeahead();
534
1743
  }
535
1744
  if let Some(s) = summary {
1745
+ if depth == 0 && !report::is_json() {
1746
+ let verifier = crate::verifier::Verifier::new(&cwd.to_string_lossy());
1747
+ let ctx = crate::verifier::VerificationContext {
1748
+ task_description: task.to_string(),
1749
+ task_type: None,
1750
+ changed_files: vec![],
1751
+ tool_calls: vec![],
1752
+ evidence_gathered: vec![],
1753
+ tests_added: vec![],
1754
+ dependencies_changed: vec![],
1755
+ git_diff: None,
1756
+ };
1757
+ let rep = verifier.verify(&ctx);
1758
+ if matches!(
1759
+ rep.status,
1760
+ crate::verifier::VerificationStatus::PassedWithWarnings
1761
+ | crate::verifier::VerificationStatus::Blocked
1762
+ | crate::verifier::VerificationStatus::Failed
1763
+ ) {
1764
+ report::notice(&format!(" [Verification: {}]", rep.status));
1765
+ if !rep.rule_violations.is_empty() {
1766
+ report::notice(&crate::rules::RuleEngine::format_violations(
1767
+ &rep.rule_violations,
1768
+ ));
1769
+ }
1770
+ }
1771
+ }
536
1772
  return Ok(s);
537
1773
  }
538
1774
  }
539
- Err(format!("reached the {MAX_ITERS}-step limit without finishing"))
1775
+ Err(format!(
1776
+ "reached the {MAX_ITERS}-step limit without finishing"
1777
+ ))
540
1778
  }
541
1779
 
542
1780
  static SUB_SEQ: AtomicUsize = AtomicUsize::new(0);
543
1781
 
544
- fn spawn_subagent(p: &Provider, perm: Permission, input: &serde_json::Value, cwd: &Path, depth: usize) -> String {
1782
+ fn spawn_subagent(
1783
+ p: &Provider,
1784
+ perm: Permission,
1785
+ input: &serde_json::Value,
1786
+ cwd: &Path,
1787
+ depth: usize,
1788
+ ) -> String {
545
1789
  if depth + 1 >= MAX_DEPTH {
546
1790
  return "subagent depth limit reached".into();
547
1791
  }
548
- let task = input["task"].as_str().unwrap_or("").trim();
1792
+ let task = input["task"]
1793
+ .as_str()
1794
+ .or_else(|| input["description"].as_str())
1795
+ .unwrap_or("")
1796
+ .trim();
549
1797
  if task.is_empty() {
550
1798
  return "spawn_subagent requires a task".into();
551
1799
  }
@@ -558,16 +1806,42 @@ fn spawn_subagent(p: &Provider, perm: Permission, input: &serde_json::Value, cwd
558
1806
  let n = format!("[isolated worktree: {}]\n", wt.display());
559
1807
  (wt, n)
560
1808
  }
561
- None => (cwd.to_path_buf(), "[worktree unavailable — ran in place]\n".into()),
1809
+ None => (
1810
+ cwd.to_path_buf(),
1811
+ "[worktree unavailable — ran in place]\n".into(),
1812
+ ),
562
1813
  }
563
1814
  } else {
564
1815
  (cwd.to_path_buf(), String::new())
565
1816
  };
566
1817
 
567
1818
  report::notice(&format!(" ↳ subagent: {task}"));
1819
+ trace::record_visible(
1820
+ "subagent_spawn",
1821
+ format!("{role}: {}", trace::preview(task, 80)),
1822
+ serde_json::json!({
1823
+ "task": task,
1824
+ "role": role,
1825
+ "isolate": isolate,
1826
+ "cwd": run_cwd.to_string_lossy(),
1827
+ "parent_depth": depth,
1828
+ }),
1829
+ );
568
1830
  let mut child: Vec<Msg> = Vec::new();
569
1831
  let result = build_inner(p, perm, role, task, &run_cwd, depth + 1, &mut child)
570
1832
  .unwrap_or_else(|e| format!("subagent error: {e}"));
1833
+ trace::record_visible(
1834
+ "subagent_done",
1835
+ format!("{role}: {}", trace::preview(task, 80)),
1836
+ serde_json::json!({
1837
+ "task": task,
1838
+ "role": role,
1839
+ "isolate": isolate,
1840
+ "cwd": run_cwd.to_string_lossy(),
1841
+ "result": result,
1842
+ "depth": depth + 1,
1843
+ }),
1844
+ );
571
1845
  format!("{note}{result}")
572
1846
  }
573
1847
 
@@ -584,63 +1858,620 @@ fn make_worktree(cwd: &Path) -> Option<PathBuf> {
584
1858
  out.status.success().then_some(wt)
585
1859
  }
586
1860
 
1861
+ fn parse_plan_steps(plan_text: &str) -> Vec<String> {
1862
+ let mut listed = Vec::new();
1863
+ for line in plan_text.lines() {
1864
+ let trimmed = line.trim();
1865
+ if trimmed.is_empty() || trimmed.starts_with("```") {
1866
+ continue;
1867
+ }
1868
+ if let Some(step) = strip_plan_marker(trimmed) {
1869
+ let step = strip_markdown_emphasis(step);
1870
+ if !step.is_empty() {
1871
+ listed.push(step);
1872
+ }
1873
+ }
1874
+ }
1875
+ if !listed.is_empty() {
1876
+ return listed.into_iter().take(8).collect();
1877
+ }
1878
+ Vec::new()
1879
+ }
1880
+
1881
+ fn plan_steps_are_actionable(steps: &[String]) -> bool {
1882
+ !steps.is_empty() && steps.iter().all(|step| plan_step_is_actionable(step))
1883
+ }
1884
+
1885
+ fn is_exit_plan_tool(name: &str) -> bool {
1886
+ matches!(name, "exit_plan" | "ExitPlanMode")
1887
+ }
1888
+
1889
+ fn fallback_exit_plan_input(task: &str) -> serde_json::Value {
1890
+ let lower = task.to_ascii_lowercase();
1891
+ let read_only_request = lower.contains("do not modify")
1892
+ || lower.contains("don't modify")
1893
+ || lower.contains("readonly")
1894
+ || lower.contains("read-only")
1895
+ || (lower.contains("list") && !lower.contains("change") && !lower.contains("fix"));
1896
+ let steps = if let Some(input) = auto_create_file_input(task) {
1897
+ let path = input["path"].as_str().unwrap_or("the requested file");
1898
+ vec![
1899
+ format!("Create {path} with the requested content."),
1900
+ format!("Verify {path} exists and contains exactly the requested content."),
1901
+ "Summarize the completed file creation.".to_string(),
1902
+ ]
1903
+ } else if read_only_request {
1904
+ vec![
1905
+ "Inspect the current workspace with read-only tools.".to_string(),
1906
+ "List the requested files, folders, or findings.".to_string(),
1907
+ "Summarize the result without modifying files.".to_string(),
1908
+ ]
1909
+ } else {
1910
+ vec![
1911
+ format!("Implement the user's requested task: {task}."),
1912
+ "Inspect only the files or directories needed for that task.".to_string(),
1913
+ "Make the required edits or generated files with the appropriate tools.".to_string(),
1914
+ "Verify the result with focused checks or tests.".to_string(),
1915
+ "Summarize the completed work and any remaining risks.".to_string(),
1916
+ ]
1917
+ };
1918
+ serde_json::json!({ "steps": steps })
1919
+ }
1920
+
1921
+ fn plan_step_is_actionable(step: &str) -> bool {
1922
+ let lower_step = step.to_ascii_lowercase();
1923
+ if lower_step.contains("exit plan")
1924
+ || lower_step.contains("exit_plan")
1925
+ || lower_step.contains("exitplanmode")
1926
+ || lower_step.contains("plan mode")
1927
+ {
1928
+ return false;
1929
+ }
1930
+ let first = step
1931
+ .trim()
1932
+ .trim_start_matches(['`', '\'', '"', '['])
1933
+ .split_whitespace()
1934
+ .next()
1935
+ .unwrap_or("")
1936
+ .trim_matches(|c: char| !c.is_ascii_alphabetic())
1937
+ .to_ascii_lowercase();
1938
+ matches!(
1939
+ first.as_str(),
1940
+ "add"
1941
+ | "analyze"
1942
+ | "audit"
1943
+ | "build"
1944
+ | "check"
1945
+ | "compare"
1946
+ | "configure"
1947
+ | "create"
1948
+ | "document"
1949
+ | "edit"
1950
+ | "find"
1951
+ | "fix"
1952
+ | "generate"
1953
+ | "identify"
1954
+ | "implement"
1955
+ | "inspect"
1956
+ | "install"
1957
+ | "list"
1958
+ | "load"
1959
+ | "open"
1960
+ | "publish"
1961
+ | "read"
1962
+ | "refactor"
1963
+ | "remove"
1964
+ | "review"
1965
+ | "run"
1966
+ | "search"
1967
+ | "start"
1968
+ | "stop"
1969
+ | "summarize"
1970
+ | "test"
1971
+ | "update"
1972
+ | "use"
1973
+ | "verify"
1974
+ | "write"
1975
+ )
1976
+ }
1977
+
1978
+ fn strip_plan_marker(line: &str) -> Option<&str> {
1979
+ if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
1980
+ return Some(rest);
1981
+ }
1982
+ let mut saw_digit = false;
1983
+ for (idx, ch) in line.char_indices() {
1984
+ if ch.is_ascii_digit() {
1985
+ saw_digit = true;
1986
+ continue;
1987
+ }
1988
+ if saw_digit && matches!(ch, '.' | ')') {
1989
+ return Some(line[idx + ch.len_utf8()..].trim_start());
1990
+ }
1991
+ break;
1992
+ }
1993
+ None
1994
+ }
1995
+
1996
+ fn strip_markdown_emphasis(line: &str) -> String {
1997
+ let mut cleaned = line.trim().to_string();
1998
+ if let Some(rest) = cleaned.strip_prefix("**") {
1999
+ cleaned = rest.to_string();
2000
+ if let Some(idx) = cleaned.rfind("**") {
2001
+ cleaned.replace_range(idx..idx + 2, "");
2002
+ }
2003
+ }
2004
+ strip_step_label(cleaned.trim()).to_string()
2005
+ }
2006
+
2007
+ fn strip_step_label(line: &str) -> &str {
2008
+ let lower = line.to_ascii_lowercase();
2009
+ if !lower.starts_with("step ") {
2010
+ return line;
2011
+ }
2012
+ let rest = &line[5..];
2013
+ let mut saw_digit = false;
2014
+ for (idx, ch) in rest.char_indices() {
2015
+ if ch.is_ascii_digit() {
2016
+ saw_digit = true;
2017
+ continue;
2018
+ }
2019
+ if saw_digit && ch == ':' {
2020
+ return rest[idx + 1..].trim_start();
2021
+ }
2022
+ break;
2023
+ }
2024
+ line
2025
+ }
2026
+
2027
+ fn auto_static_artifact_input(task: &str, text: &str) -> Option<serde_json::Value> {
2028
+ if !static_artifact_requested(task) {
2029
+ return None;
2030
+ }
2031
+ let html = extract_html_from_text(text)?;
2032
+ let title = extract_html_title(&html).unwrap_or_else(|| "static-artifact".to_string());
2033
+ Some(serde_json::json!({
2034
+ "title": title,
2035
+ "contents": html,
2036
+ "type": "html",
2037
+ }))
2038
+ }
2039
+
2040
+ fn auto_create_file_input(task: &str) -> Option<serde_json::Value> {
2041
+ let task = recovery_task_text(task);
2042
+ let lower = task.to_ascii_lowercase();
2043
+ let create_pos = lower.find("create ")?;
2044
+ let after_create = &task[create_pos + "create ".len()..];
2045
+ let after_lower = &lower[create_pos + "create ".len()..];
2046
+ let containing_pos = after_lower.find(" containing ")?;
2047
+ let mut raw_path = after_create[..containing_pos]
2048
+ .trim()
2049
+ .trim_matches(['`', '\'', '"']);
2050
+ for article in ["a ", "an ", "the "] {
2051
+ if raw_path.to_ascii_lowercase().starts_with(article) {
2052
+ raw_path = raw_path[article.len()..].trim_start();
2053
+ break;
2054
+ }
2055
+ }
2056
+ let content = after_create[containing_pos + " containing ".len()..]
2057
+ .trim()
2058
+ .trim_matches(['`', '\'', '"']);
2059
+ if raw_path.is_empty()
2060
+ || content.is_empty()
2061
+ || raw_path.contains('\n')
2062
+ || raw_path.starts_with('-')
2063
+ {
2064
+ return None;
2065
+ }
2066
+ Some(serde_json::json!({
2067
+ "path": raw_path,
2068
+ "content": content,
2069
+ }))
2070
+ }
2071
+
2072
+ fn recovery_task_text(task: &str) -> String {
2073
+ task.split("\n\nFollow this approved plan:")
2074
+ .next()
2075
+ .unwrap_or(task)
2076
+ .split("\n\n[hook context]")
2077
+ .next()
2078
+ .unwrap_or(task)
2079
+ .trim()
2080
+ .to_string()
2081
+ }
2082
+
2083
+ fn static_artifact_requested(task: &str) -> bool {
2084
+ let lower = task.to_lowercase();
2085
+ [
2086
+ "canvas game",
2087
+ "browser game",
2088
+ "static html",
2089
+ "static artifact",
2090
+ "single html",
2091
+ "standalone html",
2092
+ "landing page",
2093
+ "prototype",
2094
+ "demo",
2095
+ "website",
2096
+ "web app",
2097
+ ]
2098
+ .iter()
2099
+ .any(|needle| lower.contains(needle))
2100
+ }
2101
+
2102
+ fn static_artifact_recovery_prompt(task: &str) -> String {
2103
+ format!(
2104
+ "This is a BUILD task, not a clarification request. Proceed with reasonable defaults for: {task}\n\n\
2105
+ If this is a canvas/browser game or static web artifact, build the runnable artifact now. \
2106
+ Call Artifact/publish_artifact with one complete self-contained HTML file. \
2107
+ Include all CSS and JavaScript inline. Do not ask the user for assets or a concept unless the task is impossible without them."
2108
+ )
2109
+ }
2110
+
2111
+ fn extract_html_from_text(text: &str) -> Option<String> {
2112
+ if let Some(fenced) = extract_fenced_html(text) {
2113
+ return Some(fenced);
2114
+ }
2115
+ let lower = text.to_lowercase();
2116
+ let start = lower
2117
+ .find("<!doctype html")
2118
+ .or_else(|| lower.find("<html"))?;
2119
+ let html = text[start..].trim();
2120
+ if html.contains("</html>") {
2121
+ Some(html.to_string())
2122
+ } else {
2123
+ None
2124
+ }
2125
+ }
2126
+
2127
+ fn extract_fenced_html(text: &str) -> Option<String> {
2128
+ let mut in_html = false;
2129
+ let mut out = Vec::new();
2130
+ for line in text.lines() {
2131
+ let trimmed = line.trim_start();
2132
+ if trimmed.starts_with("```") {
2133
+ if in_html {
2134
+ return Some(out.join("\n").trim().to_string());
2135
+ }
2136
+ let tag = trimmed.trim_start_matches("```").trim().to_lowercase();
2137
+ if tag == "html" || tag.is_empty() {
2138
+ in_html = true;
2139
+ out.clear();
2140
+ }
2141
+ continue;
2142
+ }
2143
+ if in_html {
2144
+ out.push(line);
2145
+ }
2146
+ }
2147
+ None
2148
+ }
2149
+
2150
+ fn extract_html_title(html: &str) -> Option<String> {
2151
+ let lower = html.to_lowercase();
2152
+ let start = lower.find("<title")?;
2153
+ let after_tag = &html[start..];
2154
+ let gt = after_tag.find('>')?;
2155
+ let rest = &after_tag[gt + 1..];
2156
+ let end = rest.to_lowercase().find("</title")?;
2157
+ let title = rest[..end].trim();
2158
+ (!title.is_empty()).then(|| title.to_string())
2159
+ }
2160
+
2161
+ fn canvas_game_requested(task: &str) -> bool {
2162
+ let lower = task.to_lowercase();
2163
+ lower.contains("canvas game") || lower.contains("browser game") || lower.contains("game")
2164
+ }
2165
+
2166
+ fn fallback_canvas_game_input(task: &str) -> serde_json::Value {
2167
+ let title = if task.to_lowercase().contains("orbit") {
2168
+ "Orbit Dodge"
2169
+ } else {
2170
+ "Canvas Dodge"
2171
+ };
2172
+ let html = format!(
2173
+ r#"<!doctype html>
2174
+ <html lang="en">
2175
+ <head>
2176
+ <meta charset="utf-8">
2177
+ <meta name="viewport" content="width=device-width,initial-scale=1">
2178
+ <title>{title}</title>
2179
+ <style>
2180
+ html,body{{margin:0;height:100%;overflow:hidden;background:#111827;color:#e5e7eb;font-family:system-ui,-apple-system,Segoe UI,sans-serif}}
2181
+ #wrap{{position:fixed;inset:0;display:grid;place-items:center;background:radial-gradient(circle at 50% 35%,#1f2937,#030712 70%)}}
2182
+ canvas{{width:min(94vw,860px);height:min(72vh,560px);background:#050816;border:1px solid #334155;box-shadow:0 24px 80px #0008}}
2183
+ #hud{{position:fixed;left:18px;right:18px;top:14px;display:flex;justify-content:space-between;gap:16px;font-weight:700;text-shadow:0 2px 8px #000}}
2184
+ #help{{position:fixed;left:18px;right:18px;bottom:14px;text-align:center;color:#9ca3af;font-size:14px}}
2185
+ #pad{{position:fixed;right:18px;bottom:56px;display:grid;grid-template-columns:48px 48px 48px;gap:8px}}
2186
+ button{{background:#1f2937cc;color:#e5e7eb;border:1px solid #475569;border-radius:8px;min-height:44px;font:inherit}}
2187
+ @media (pointer:fine){{#pad{{display:none}}}}
2188
+ </style>
2189
+ </head>
2190
+ <body>
2191
+ <div id="wrap"><canvas id="game" width="860" height="560"></canvas></div>
2192
+ <div id="hud"><span id="score">Score 0</span><span id="state">Move to start</span></div>
2193
+ <div id="help">WASD/Arrows move · Space/P pause · R restart · Dodge the orbiting sparks</div>
2194
+ <div id="pad"><span></span><button data-k="ArrowUp">↑</button><span></span><button data-k="ArrowLeft">←</button><button data-k=" ">Ⅱ</button><button data-k="ArrowRight">→</button><span></span><button data-k="ArrowDown">↓</button><span></span></div>
2195
+ <script>
2196
+ const c=document.getElementById('game'),x=c.getContext('2d'),scoreEl=document.getElementById('score'),stateEl=document.getElementById('state');
2197
+ const keys=new Set();let player,orbs,score,best=0,over=false,paused=false,last=0;
2198
+ function reset(){{player={{x:c.width/2,y:c.height/2,r:11,vx:0,vy:0}};orbs=[];score=0;over=false;paused=false;last=0;stateEl.textContent='Survive';for(let i=0;i<7;i++)orbs.push({{a:i*.9,d:70+i*34,s:.7+i*.08,r:8+i%3}});}}
2199
+ function key(k,on){{if(on)keys.add(k);else keys.delete(k);if(k==='r'||k==='R')reset();if(k===' '||k==='p'||k==='P')paused=!paused;}}
2200
+ addEventListener('keydown',e=>{{key(e.key,true);if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key))e.preventDefault();}});
2201
+ addEventListener('keyup',e=>key(e.key,false));
2202
+ document.querySelectorAll('button').forEach(b=>{{b.onpointerdown=e=>{{b.setPointerCapture(e.pointerId);key(b.dataset.k,true)}};b.onpointerup=e=>key(b.dataset.k,false);}});
2203
+ function step(t){{requestAnimationFrame(step);let dt=Math.min(.033,(t-last)/1000||0);last=t;if(paused){{draw();stateEl.textContent='Paused';return}}if(over){{draw();stateEl.textContent='Game over · R to restart';return}}
2204
+ let ax=(keys.has('ArrowRight')||keys.has('d')||keys.has('D'))-(keys.has('ArrowLeft')||keys.has('a')||keys.has('A'));
2205
+ let ay=(keys.has('ArrowDown')||keys.has('s')||keys.has('S'))-(keys.has('ArrowUp')||keys.has('w')||keys.has('W'));
2206
+ player.vx=(player.vx+ax*900*dt)*.86;player.vy=(player.vy+ay*900*dt)*.86;player.x=Math.max(player.r,Math.min(c.width-player.r,player.x+player.vx*dt));player.y=Math.max(player.r,Math.min(c.height-player.r,player.y+player.vy*dt));
2207
+ score+=dt*10;best=Math.max(best,score);scoreEl.textContent=`Score ${{score|0}} · Best ${{best|0}}`;
2208
+ for(const o of orbs){{o.a+=o.s*dt;let ox=c.width/2+Math.cos(o.a)*o.d,oy=c.height/2+Math.sin(o.a*1.35)*o.d;if(Math.hypot(player.x-ox,player.y-oy)<player.r+o.r)over=true;}}
2209
+ draw();}}
2210
+ function draw(){{x.clearRect(0,0,c.width,c.height);x.fillStyle='#111827';x.fillRect(0,0,c.width,c.height);x.strokeStyle='#1f9cf0';x.globalAlpha=.18;for(let r=70;r<330;r+=34){{x.beginPath();x.ellipse(c.width/2,c.height/2,r,r*.72,0,0,7);x.stroke();}}x.globalAlpha=1;for(const o of orbs){{let ox=c.width/2+Math.cos(o.a)*o.d,oy=c.height/2+Math.sin(o.a*1.35)*o.d;x.fillStyle='#f97316';x.beginPath();x.arc(ox,oy,o.r,0,7);x.fill();}}x.fillStyle=over?'#ef4444':'#22c55e';x.beginPath();x.arc(player.x,player.y,player.r,0,7);x.fill();}}
2211
+ reset();requestAnimationFrame(step);
2212
+ </script>
2213
+ </body>
2214
+ </html>"#
2215
+ );
2216
+ serde_json::json!({
2217
+ "title": title,
2218
+ "contents": html,
2219
+ "type": "html",
2220
+ })
2221
+ }
2222
+
587
2223
  // ── PLAN mode ─────────────────────────────────────────────────────────────────
588
2224
  // The planning phase now has tools available so the model can inspect the
589
2225
  // codebase while breaking down the task. Execution still runs through BUILD.
590
2226
  pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Result<(), String> {
591
- let prefix = context_prefix();
592
- let sys = format!("{prefix}You are a planning engineer with full access to the codebase. \
593
- Use read_file and list_dir and run_command to inspect the project as needed. \
594
- Break the user's task into a concise numbered list of concrete steps \
595
- (one step per line, max 8 steps). Output the numbered list and nothing else.");
596
-
597
- let defs = tools::defs(false); // planning uses tools but not subagents
2227
+ let prefix = context_prefix(cwd, p.context_tokens);
2228
+ let sys = format!(
2229
+ "{prefix}You are a planning engineer with full access to the codebase. \
2230
+ Use read_file/list_dir/list_tree/find_paths/grep_files/fetch_url and read-only bash/run_command calls to inspect the project as needed. \
2231
+ Do not write files, edit files, apply patches, spawn subagents, or run mutating shell commands while planning. \
2232
+ When ready, call exit_plan or ExitPlanMode with a concise numbered implementation plan. \
2233
+ The plan must be concrete, actionable, and at most 8 steps. \
2234
+ Do not include code fences, shell snippets, intro text, or outro text."
2235
+ );
2236
+
2237
+ let defs = tools::defs_readonly(); // planning inspects context but never writes
598
2238
  let mut msgs = vec![Msg::System(sys), Msg::User(task.into())];
2239
+ let mut loop_guard = ToolLoopGuard::default();
2240
+ let mut tool_rounds = 0usize;
2241
+ let mut plan_format_recovery_count = 0usize;
2242
+ let mut plan_loop_recovery_count = 0usize;
599
2243
 
600
2244
  // Let the model use tools while planning (e.g. read files to understand structure).
601
- let plan_text = loop {
2245
+ let plan_text = 'planning: loop {
2246
+ tool_rounds += 1;
2247
+ if tool_rounds > MAX_PLAN_TOOL_ROUNDS {
2248
+ return Err(format!(
2249
+ "planning stopped after {MAX_PLAN_TOOL_ROUNDS} tool rounds without producing a plan"
2250
+ ));
2251
+ }
602
2252
  maybe_compact(p, &mut msgs);
603
- let reply = tui::with_spinner("planning…", || complete(p, &msgs, &defs))?;
2253
+ let start_instant = std::time::Instant::now();
2254
+ let reply = request_reply(p, &msgs, &defs, "planning")?;
2255
+ let reply = normalize_text_tool_calls(reply, &defs, task);
2256
+ let elapsed = start_instant.elapsed().as_secs_f64();
2257
+ let gen_toks = (reply.text.len() / 4) + (reply.calls.len() * 40);
2258
+ if !report::is_json() {
2259
+ tui::inference_telemetry(gen_toks.max(10), elapsed);
2260
+ }
604
2261
 
605
2262
  if reply.calls.is_empty() {
2263
+ let candidate_steps = parse_plan_steps(&reply.text);
2264
+ if !plan_steps_are_actionable(&candidate_steps) && plan_format_recovery_count < 2 {
2265
+ plan_format_recovery_count += 1;
2266
+ msgs.push(Msg::Assistant {
2267
+ text: reply.text.clone(),
2268
+ calls: vec![],
2269
+ });
2270
+ msgs.push(Msg::User(format!(
2271
+ "That was not a valid plan. Use the actual current workspace, not placeholder paths: {}\n\
2272
+ If you need context, call read-only tools now. Otherwise output only a concise numbered list of concrete steps, max 8, with no prose.",
2273
+ cwd.display()
2274
+ )));
2275
+ continue;
2276
+ }
2277
+ if !plan_steps_are_actionable(&candidate_steps) {
2278
+ let fallback = fallback_exit_plan_input(task);
2279
+ report::tool_call(
2280
+ "exit_plan",
2281
+ &tools::preview("exit_plan", &fallback),
2282
+ &fallback,
2283
+ );
2284
+ trace_tool_call("exit_plan", &fallback, "plan", 0);
2285
+ let out = tools::run("exit_plan", &fallback, cwd);
2286
+ report::tool_result("exit_plan", &out.content, out.is_error);
2287
+ trace_tool_result("exit_plan", &out.content, out.is_error, "plan", 0);
2288
+ if !out.is_error && plan_steps_are_actionable(&parse_plan_steps(&out.content)) {
2289
+ break out.content;
2290
+ }
2291
+ return Err(out.content);
2292
+ }
606
2293
  break reply.text;
607
2294
  }
608
2295
 
609
2296
  // Execute tool calls during the planning phase.
610
2297
  let mut results = Vec::new();
2298
+ let mut loop_summary: Option<String> = None;
611
2299
  for call in &reply.calls {
612
- report::tool_call(&call.name, &tools::preview(&call.name, &call.input), &call.input);
613
- let reason = match hooks::pre_tool_use(&call.name, &call.input, cwd) {
2300
+ if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
2301
+ let msg = format!(
2302
+ "tool arguments were not valid JSON: {}",
2303
+ raw.chars().take(200).collect::<String>()
2304
+ );
2305
+ report::tool_denied(&msg);
2306
+ note_loop_result(
2307
+ &mut loop_guard,
2308
+ &call.name,
2309
+ &call.input,
2310
+ &msg,
2311
+ true,
2312
+ &mut loop_summary,
2313
+ );
2314
+ results.push(ToolResult {
2315
+ id: call.id.clone(),
2316
+ content: msg,
2317
+ is_error: true,
2318
+ });
2319
+ continue;
2320
+ }
2321
+ let call_input =
2322
+ tool_input_for_execution(&call.name, &call.input, cwd, "plan", 0, task);
2323
+ report::tool_call(
2324
+ &call.name,
2325
+ &tools::preview(&call.name, &call_input),
2326
+ &call_input,
2327
+ );
2328
+ trace_tool_call(&call.name, &call_input, "plan", 0);
2329
+ if is_exit_plan_tool(&call.name) {
2330
+ let out = tools::run(&call.name, &call_input, cwd);
2331
+ let steps = parse_plan_steps(&out.content);
2332
+ let invalid_plan = out.is_error || !plan_steps_are_actionable(&steps);
2333
+ let content = if invalid_plan && !out.is_error {
2334
+ format!(
2335
+ "exit_plan rejected: provide an actionable numbered or bulleted plan for the user's task, not prose, a file list, or a description of exit_plan itself.\nCurrent user task: {task}\nCall exit_plan again with concrete task steps."
2336
+ )
2337
+ } else {
2338
+ out.content.clone()
2339
+ };
2340
+ report::tool_result(&call.name, &content, invalid_plan);
2341
+ trace_tool_result(&call.name, &content, invalid_plan, "plan", 0);
2342
+ if invalid_plan {
2343
+ results.push(ToolResult {
2344
+ id: call.id.clone(),
2345
+ content,
2346
+ is_error: true,
2347
+ });
2348
+ let fallback = fallback_exit_plan_input(task);
2349
+ report::tool_call(
2350
+ "exit_plan",
2351
+ &tools::preview("exit_plan", &fallback),
2352
+ &fallback,
2353
+ );
2354
+ trace_tool_call("exit_plan", &fallback, "plan", 0);
2355
+ let fallback_out = tools::run("exit_plan", &fallback, cwd);
2356
+ report::tool_result("exit_plan", &fallback_out.content, fallback_out.is_error);
2357
+ trace_tool_result(
2358
+ "exit_plan",
2359
+ &fallback_out.content,
2360
+ fallback_out.is_error,
2361
+ "plan",
2362
+ 0,
2363
+ );
2364
+ if !fallback_out.is_error
2365
+ && plan_steps_are_actionable(&parse_plan_steps(&fallback_out.content))
2366
+ {
2367
+ break 'planning fallback_out.content;
2368
+ }
2369
+ return Err(fallback_out.content);
2370
+ }
2371
+ break 'planning out.content;
2372
+ }
2373
+ if call.name == "question" || call.name == "AskUserQuestion" {
2374
+ let (answer, is_error) = answer_question(&call_input);
2375
+ report::tool_result(&call.name, &answer, is_error);
2376
+ trace_tool_result(&call.name, &answer, is_error, "plan", 0);
2377
+ note_loop_result(
2378
+ &mut loop_guard,
2379
+ &call.name,
2380
+ &call_input,
2381
+ &answer,
2382
+ is_error,
2383
+ &mut loop_summary,
2384
+ );
2385
+ results.push(ToolResult {
2386
+ id: call.id.clone(),
2387
+ content: answer,
2388
+ is_error,
2389
+ });
2390
+ continue;
2391
+ }
2392
+ let reason = match hooks::pre_tool_use(&call.name, &call_input, cwd) {
614
2393
  PreDecision::Deny(r) => Some(r),
615
2394
  PreDecision::Allow => None,
616
- PreDecision::Continue => gate(perm, &call.name, &call.input, cwd),
2395
+ PreDecision::Continue => gate(Permission::ReadOnly, &call.name, &call_input, cwd),
617
2396
  };
618
2397
  if let Some(reason) = reason {
619
- results.push(ToolResult { id: call.id.clone(), content: reason, is_error: true });
2398
+ trace::record_visible(
2399
+ "tool_denied",
2400
+ format!("{} denied", call.name),
2401
+ serde_json::json!({"tool": call.name, "reason": reason, "input": &call_input, "phase": "plan", "depth": 0}),
2402
+ );
2403
+ note_loop_result(
2404
+ &mut loop_guard,
2405
+ &call.name,
2406
+ &call_input,
2407
+ &reason,
2408
+ true,
2409
+ &mut loop_summary,
2410
+ );
2411
+ results.push(ToolResult {
2412
+ id: call.id.clone(),
2413
+ content: reason,
2414
+ is_error: true,
2415
+ });
620
2416
  continue;
621
2417
  }
622
2418
  if call.name == "save_memory" {
623
- if let Some(note) = call.input["note"].as_str() {
2419
+ if let Some(note) = call_input["note"].as_str() {
624
2420
  config::append_memory(note);
625
2421
  let msg = "memory saved".to_string();
626
2422
  report::tool_result(&call.name, &msg, false);
627
- results.push(ToolResult { id: call.id.clone(), content: msg, is_error: false });
2423
+ trace_tool_result(&call.name, &msg, false, "plan", 0);
2424
+ results.push(ToolResult {
2425
+ id: call.id.clone(),
2426
+ content: msg,
2427
+ is_error: false,
2428
+ });
628
2429
  continue;
629
2430
  }
630
2431
  }
631
- let out = tools::run(&call.name, &call.input, cwd);
2432
+ let out = tools::run(&call.name, &call_input, cwd);
632
2433
  report::tool_result(&call.name, &out.content, out.is_error);
633
- results.push(ToolResult { id: call.id.clone(), content: out.content, is_error: out.is_error });
2434
+ trace_tool_result(&call.name, &out.content, out.is_error, "plan", 0);
2435
+ note_loop_result(
2436
+ &mut loop_guard,
2437
+ &call.name,
2438
+ &call_input,
2439
+ &out.content,
2440
+ out.is_error,
2441
+ &mut loop_summary,
2442
+ );
2443
+ results.push(ToolResult {
2444
+ id: call.id.clone(),
2445
+ content: out.content,
2446
+ is_error: out.is_error,
2447
+ });
634
2448
  }
635
- msgs.push(Msg::Assistant { text: reply.text, calls: reply.calls });
2449
+ msgs.push(Msg::Assistant {
2450
+ text: assistant_tool_turn_text(reply.text, &reply.calls),
2451
+ calls: reply.calls,
2452
+ });
636
2453
  msgs.push(Msg::Tool(results));
2454
+ if let Some(loop_msg) = loop_summary {
2455
+ if plan_loop_recovery_count < 2 {
2456
+ plan_loop_recovery_count += 1;
2457
+ msgs.push(Msg::User(format!(
2458
+ "You are repeating the same inspection instead of exiting plan mode.\n\
2459
+ Use the gathered result below and call exit_plan/ExitPlanMode now with a concise actionable plan. \
2460
+ Do not call the same tool again.\n\n{loop_msg}"
2461
+ )));
2462
+ continue;
2463
+ }
2464
+ return Err(loop_msg);
2465
+ }
637
2466
  };
638
2467
 
639
- let mut steps: Vec<String> = plan_text.lines()
640
- .map(|l| l.trim_start_matches(|c: char| c.is_ascii_digit() || matches!(c, '.' | ')' | '-' | ' ')).trim())
641
- .filter(|l| !l.is_empty())
642
- .map(|l| l.to_string())
643
- .collect();
2468
+ let mut steps = parse_plan_steps(&plan_text);
2469
+ if !plan_steps_are_actionable(&steps) {
2470
+ return Err(
2471
+ "planning did not produce an actionable numbered or bulleted plan after recovery attempts"
2472
+ .into(),
2473
+ );
2474
+ }
644
2475
 
645
2476
  loop {
646
2477
  tui::line("");
@@ -649,8 +2480,13 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
649
2480
  tui::line(&format!(" {}. {}", i + 1, s));
650
2481
  }
651
2482
  tui::line("");
652
- let ans = tui::ask(&format!(" {} execute, {} edit <n>, {} cancel: ",
653
- tui::bold("[Enter]"), tui::bold("e"), tui::bold("c"))).unwrap_or_default();
2483
+ let ans = tui::ask(&format!(
2484
+ " {} execute, {} edit <n>, {} cancel: ",
2485
+ tui::bold("[Enter]"),
2486
+ tui::bold("e"),
2487
+ tui::bold("c")
2488
+ ))
2489
+ .unwrap_or_default();
654
2490
  let a = ans.trim();
655
2491
  if a.is_empty() || a == "y" {
656
2492
  break;
@@ -672,18 +2508,41 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
672
2508
  }
673
2509
  }
674
2510
 
675
- let plan = steps.iter().enumerate().map(|(i, s)| format!("{}. {}", i + 1, s)).collect::<Vec<_>>().join("\n");
676
- let full = format!("{task}\n\nFollow this approved plan:\n{plan}");
2511
+ let plan = steps
2512
+ .iter()
2513
+ .enumerate()
2514
+ .map(|(i, s)| format!("{}. {}", i + 1, s))
2515
+ .collect::<Vec<_>>()
2516
+ .join("\n");
2517
+ let full = approved_plan_build_task(task, &plan);
677
2518
  run_build(p, perm, "engineer", &full, cwd)
678
2519
  }
679
2520
 
2521
+ fn approved_plan_build_task(task: &str, plan: &str) -> String {
2522
+ format!(
2523
+ "{task}\n\nFollow this approved plan:\n{plan}\n\n\
2524
+ BUILD EXECUTION DIRECTIVE:\n\
2525
+ - The user already approved this plan. Do not re-plan, ask to proceed, or answer with a plan.\n\
2526
+ - Execute the approved steps now using tools.\n\
2527
+ - If the task requires files/artifacts, create or edit them on disk instead of pasting code in chat.\n\
2528
+ - If the task requires a server or browser check, use start_server, wait_for_url, read_server_log, and open_browser where appropriate.\n\
2529
+ - Run focused verification when possible.\n\
2530
+ - Finish with the finish tool once the approved plan has been executed."
2531
+ )
2532
+ }
2533
+
680
2534
  // ── BRAINSTORM mode ───────────────────────────────────────────────────────────
681
2535
  // Brainstorm is conversational but has full tool access. The model can grep,
682
2536
  // read files, fetch URLs, and run commands when the conversation calls for it.
683
2537
  // It also has a mode-transition sensor: if it detects the user wants to build
684
2538
  // or plan, it suggests switching.
685
- pub fn run_brainstorm(p: &Provider, perm: Permission, cwd: &Path, first: &str) -> Result<Option<ModeHint>, String> {
686
- let prefix = context_prefix();
2539
+ pub fn run_brainstorm(
2540
+ p: &Provider,
2541
+ perm: Permission,
2542
+ cwd: &Path,
2543
+ first: &str,
2544
+ ) -> Result<Option<ModeHint>, String> {
2545
+ let prefix = context_prefix(cwd, p.context_tokens);
687
2546
  let sys = format!("{prefix}You are a sharp, concise thought partner with full access to the codebase and the internet. \
688
2547
  Use tools freely to look things up, read files, grep for patterns, or run commands — \
689
2548
  whatever helps the conversation. \
@@ -691,42 +2550,39 @@ pub fn run_brainstorm(p: &Provider, perm: Permission, cwd: &Path, first: &str) -
691
2550
  end your response with the exact token [SUGGEST:BUILD] or [SUGGEST:PLAN] on its own line. \
692
2551
  Otherwise just respond naturally. No fluff.");
693
2552
 
694
- let defs = tools::defs(false);
2553
+ let defs = tools::defs_for_context(false, p.context_tokens);
695
2554
  let mut msgs: Vec<Msg> = vec![Msg::System(sys)];
696
2555
  let mut question = first.to_string();
2556
+ let mut loop_guard = ToolLoopGuard::default();
697
2557
 
698
2558
  loop {
699
2559
  msgs.push(Msg::User(question.clone()));
700
2560
  maybe_compact(p, &mut msgs);
701
2561
 
702
2562
  // Keep consuming tool calls until the model gives a text response.
2563
+ let mut tool_rounds = 0usize;
703
2564
  let reply_text = loop {
2565
+ tool_rounds += 1;
2566
+ if tool_rounds > MAX_CHAT_TOOL_ROUNDS {
2567
+ break format!(
2568
+ "I stopped after {MAX_CHAT_TOOL_ROUNDS} tool rounds without a final response."
2569
+ );
2570
+ }
704
2571
  tui::line("");
705
- let mut spin = Some(tui::spinner_start("thinking…"));
706
- let mut streamed = false;
707
- let mut thinking_buf = String::new();
708
- let mut renderer = if !report::is_json() { Some(tui::StreamRenderer::new()) } else { None };
709
- let res = provider::stream(p, &msgs, &defs, &mut |c| {
710
- if let Some(s) = spin.take() { tui::spinner_stop(s); }
711
- if let Some(r) = &mut renderer { r.push(c); } else { report::assistant_delta(c); }
712
- streamed = true;
713
- }, &mut |t| {
714
- thinking_buf.push_str(t);
715
- while let Some(nl) = thinking_buf.find('\n') {
716
- let tl = thinking_buf[..nl].trim().to_string();
717
- if !tl.is_empty() {
718
- tui::write_stream(&format!("\r {} {}\r\n", tui::dim("◌"), tui::dim(&tl)));
719
- }
720
- thinking_buf = thinking_buf[nl + 1..].to_string();
721
- }
722
- });
723
- if let Some(s) = spin.take() { tui::spinner_stop(s); }
724
- let reply = res?;
725
- if let Some(r) = &mut renderer { r.flush(); }
726
- if streamed { report::assistant_end(); }
2572
+ let start_instant = std::time::Instant::now();
2573
+ let reply = request_reply(p, &msgs, &defs, "thinking")?;
2574
+ let reply = normalize_text_tool_calls(reply, &defs, &question);
2575
+ let elapsed = start_instant.elapsed().as_secs_f64();
2576
+ let gen_toks = (reply.text.len() / 4) + (reply.calls.len() * 40);
2577
+ if !report::is_json() {
2578
+ tui::inference_telemetry(gen_toks.max(10), elapsed);
2579
+ }
727
2580
 
728
2581
  if reply.calls.is_empty() {
729
- msgs.push(Msg::Assistant { text: reply.text.clone(), calls: vec![] });
2582
+ msgs.push(Msg::Assistant {
2583
+ text: reply.text.clone(),
2584
+ calls: vec![],
2585
+ });
730
2586
  if !report::is_json() {
731
2587
  tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
732
2588
  }
@@ -735,37 +2591,132 @@ pub fn run_brainstorm(p: &Provider, perm: Permission, cwd: &Path, first: &str) -
735
2591
 
736
2592
  // Execute tool calls inline.
737
2593
  let mut results = Vec::new();
2594
+ let mut loop_summary: Option<String> = None;
738
2595
  for call in &reply.calls {
739
- report::tool_call(&call.name, &tools::preview(&call.name, &call.input), &call.input);
740
- let reason = match hooks::pre_tool_use(&call.name, &call.input, cwd) {
2596
+ if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
2597
+ let msg = format!(
2598
+ "tool arguments were not valid JSON: {}",
2599
+ raw.chars().take(200).collect::<String>()
2600
+ );
2601
+ report::tool_denied(&msg);
2602
+ note_loop_result(
2603
+ &mut loop_guard,
2604
+ &call.name,
2605
+ &call.input,
2606
+ &msg,
2607
+ true,
2608
+ &mut loop_summary,
2609
+ );
2610
+ results.push(ToolResult {
2611
+ id: call.id.clone(),
2612
+ content: msg,
2613
+ is_error: true,
2614
+ });
2615
+ continue;
2616
+ }
2617
+ let call_input = tool_input_for_execution(
2618
+ &call.name,
2619
+ &call.input,
2620
+ cwd,
2621
+ "brainstorm",
2622
+ 0,
2623
+ &question,
2624
+ );
2625
+ report::tool_call(
2626
+ &call.name,
2627
+ &tools::preview(&call.name, &call_input),
2628
+ &call_input,
2629
+ );
2630
+ trace_tool_call(&call.name, &call_input, "brainstorm", 0);
2631
+ if call.name == "question" || call.name == "AskUserQuestion" {
2632
+ let (answer, is_error) = answer_question(&call_input);
2633
+ report::tool_result(&call.name, &answer, is_error);
2634
+ trace_tool_result(&call.name, &answer, is_error, "brainstorm", 0);
2635
+ note_loop_result(
2636
+ &mut loop_guard,
2637
+ &call.name,
2638
+ &call_input,
2639
+ &answer,
2640
+ is_error,
2641
+ &mut loop_summary,
2642
+ );
2643
+ results.push(ToolResult {
2644
+ id: call.id.clone(),
2645
+ content: answer,
2646
+ is_error,
2647
+ });
2648
+ continue;
2649
+ }
2650
+ let reason = match hooks::pre_tool_use(&call.name, &call_input, cwd) {
741
2651
  PreDecision::Deny(r) => Some(r),
742
2652
  PreDecision::Allow => None,
743
- PreDecision::Continue => gate(perm, &call.name, &call.input, cwd),
2653
+ PreDecision::Continue => gate(perm, &call.name, &call_input, cwd),
744
2654
  };
745
2655
  if let Some(reason) = reason {
746
- results.push(ToolResult { id: call.id.clone(), content: reason, is_error: true });
2656
+ trace::record_visible(
2657
+ "tool_denied",
2658
+ format!("{} denied", call.name),
2659
+ serde_json::json!({"tool": call.name, "reason": reason, "input": &call_input, "phase": "brainstorm", "depth": 0}),
2660
+ );
2661
+ note_loop_result(
2662
+ &mut loop_guard,
2663
+ &call.name,
2664
+ &call_input,
2665
+ &reason,
2666
+ true,
2667
+ &mut loop_summary,
2668
+ );
2669
+ results.push(ToolResult {
2670
+ id: call.id.clone(),
2671
+ content: reason,
2672
+ is_error: true,
2673
+ });
747
2674
  continue;
748
2675
  }
749
2676
  if call.name == "save_memory" {
750
- if let Some(note) = call.input["note"].as_str() {
2677
+ if let Some(note) = call_input["note"].as_str() {
751
2678
  config::append_memory(note);
752
2679
  let msg = "memory saved".to_string();
753
2680
  report::tool_result(&call.name, &msg, false);
754
- results.push(ToolResult { id: call.id.clone(), content: msg, is_error: false });
2681
+ trace_tool_result(&call.name, &msg, false, "brainstorm", 0);
2682
+ results.push(ToolResult {
2683
+ id: call.id.clone(),
2684
+ content: msg,
2685
+ is_error: false,
2686
+ });
755
2687
  continue;
756
2688
  }
757
2689
  }
758
- let out = tools::run(&call.name, &call.input, cwd);
759
- hooks::post_tool_use(&call.name, &call.input, &out.content, out.is_error, cwd);
2690
+ let out = tools::run(&call.name, &call_input, cwd);
2691
+ hooks::post_tool_use(&call.name, &call_input, &out.content, out.is_error, cwd);
760
2692
  report::tool_result(&call.name, &out.content, out.is_error);
761
- results.push(ToolResult { id: call.id.clone(), content: out.content, is_error: out.is_error });
2693
+ trace_tool_result(&call.name, &out.content, out.is_error, "brainstorm", 0);
2694
+ note_loop_result(
2695
+ &mut loop_guard,
2696
+ &call.name,
2697
+ &call_input,
2698
+ &out.content,
2699
+ out.is_error,
2700
+ &mut loop_summary,
2701
+ );
2702
+ results.push(ToolResult {
2703
+ id: call.id.clone(),
2704
+ content: out.content,
2705
+ is_error: out.is_error,
2706
+ });
762
2707
  }
763
- msgs.push(Msg::Assistant { text: reply.text, calls: reply.calls });
2708
+ msgs.push(Msg::Assistant {
2709
+ text: assistant_tool_turn_text(reply.text, &reply.calls),
2710
+ calls: reply.calls,
2711
+ });
764
2712
  msgs.push(Msg::Tool(results));
765
2713
  if !report::is_json() {
766
2714
  tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
767
2715
  tui::poll_typeahead();
768
2716
  }
2717
+ if let Some(loop_msg) = loop_summary {
2718
+ break loop_msg;
2719
+ }
769
2720
  };
770
2721
 
771
2722
  // Check for mode-transition suggestion embedded in the reply.
@@ -780,8 +2731,8 @@ pub fn run_brainstorm(p: &Provider, perm: Permission, cwd: &Path, first: &str) -
780
2731
  if let Some(ref h) = hint {
781
2732
  tui::line("");
782
2733
  let suggestion = match h {
783
- ModeHint::Build => "switch to BUILD mode and implement this?",
784
- ModeHint::Plan => "switch to PLAN mode and break this down?",
2734
+ ModeHint::Build => "switch to BUILD mode and implement this?",
2735
+ ModeHint::Plan => "switch to PLAN mode and break this down?",
785
2736
  ModeHint::CycleMode => "cycle to the next mode?",
786
2737
  };
787
2738
  tui::line(&tui::yellow(&format!(" ↪ AI suggests: {suggestion}")));
@@ -807,6 +2758,166 @@ pub fn run_brainstorm(p: &Provider, perm: Permission, cwd: &Path, first: &str) -
807
2758
  }
808
2759
  }
809
2760
 
2761
+ pub fn run_chat_turn(
2762
+ p: &Provider,
2763
+ perm: Permission,
2764
+ cwd: &Path,
2765
+ question: &str,
2766
+ ) -> Result<(), String> {
2767
+ let prefix = context_prefix(cwd, p.context_tokens);
2768
+ let sys = format!(
2769
+ "{prefix}You are buildwithnexus in a coding terminal. Answer the user's current message naturally and concisely. \
2770
+ If the user asks a normal conversational question or greeting, answer in plain text and do not call tools. \
2771
+ If answering well requires inspecting the workspace or environment, use tools, then summarize the result. \
2772
+ Do not emit JSON unless a tool call is actually required by the tool protocol."
2773
+ );
2774
+
2775
+ let defs = tools::defs_for_context(false, p.context_tokens);
2776
+ let mut msgs: Vec<Msg> = vec![Msg::System(sys), Msg::User(question.to_string())];
2777
+ let mut loop_guard = ToolLoopGuard::default();
2778
+
2779
+ for tool_round in 1..=MAX_CHAT_TOOL_ROUNDS {
2780
+ maybe_compact(p, &mut msgs);
2781
+ let start_instant = std::time::Instant::now();
2782
+ let reply = request_reply(p, &msgs, &defs, "thinking")?;
2783
+ let reply = normalize_text_tool_calls(reply, &defs, question);
2784
+ let elapsed = start_instant.elapsed().as_secs_f64();
2785
+ let gen_toks = (reply.text.len() / 4) + (reply.calls.len() * 40);
2786
+ if !report::is_json() {
2787
+ tui::inference_telemetry(gen_toks.max(10), elapsed);
2788
+ }
2789
+
2790
+ if reply.calls.is_empty() {
2791
+ if !reply.text.trim().is_empty() && !report::is_json() {
2792
+ tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
2793
+ }
2794
+ return Ok(());
2795
+ }
2796
+
2797
+ let mut results = Vec::new();
2798
+ let mut loop_summary: Option<String> = None;
2799
+ for call in &reply.calls {
2800
+ if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
2801
+ let msg = format!(
2802
+ "tool arguments were not valid JSON: {}",
2803
+ raw.chars().take(200).collect::<String>()
2804
+ );
2805
+ report::tool_denied(&msg);
2806
+ note_loop_result(
2807
+ &mut loop_guard,
2808
+ &call.name,
2809
+ &call.input,
2810
+ &msg,
2811
+ true,
2812
+ &mut loop_summary,
2813
+ );
2814
+ results.push(ToolResult {
2815
+ id: call.id.clone(),
2816
+ content: msg,
2817
+ is_error: true,
2818
+ });
2819
+ continue;
2820
+ }
2821
+
2822
+ let call_input = tool_input_for_execution(
2823
+ &call.name,
2824
+ &call.input,
2825
+ cwd,
2826
+ "chat",
2827
+ tool_round,
2828
+ question,
2829
+ );
2830
+ report::tool_call(
2831
+ &call.name,
2832
+ &tools::preview(&call.name, &call_input),
2833
+ &call_input,
2834
+ );
2835
+ trace_tool_call(&call.name, &call_input, "chat", tool_round);
2836
+
2837
+ let reason = match hooks::pre_tool_use(&call.name, &call_input, cwd) {
2838
+ PreDecision::Deny(r) => Some(r),
2839
+ PreDecision::Allow => None,
2840
+ PreDecision::Continue => gate(perm, &call.name, &call_input, cwd),
2841
+ };
2842
+ if let Some(reason) = reason {
2843
+ report::tool_denied(&reason);
2844
+ trace::record_visible(
2845
+ "tool_denied",
2846
+ format!("{} denied", call.name),
2847
+ serde_json::json!({"tool": call.name, "reason": reason, "input": &call_input, "phase": "chat", "depth": tool_round}),
2848
+ );
2849
+ note_loop_result(
2850
+ &mut loop_guard,
2851
+ &call.name,
2852
+ &call_input,
2853
+ &reason,
2854
+ true,
2855
+ &mut loop_summary,
2856
+ );
2857
+ results.push(ToolResult {
2858
+ id: call.id.clone(),
2859
+ content: reason,
2860
+ is_error: true,
2861
+ });
2862
+ continue;
2863
+ }
2864
+
2865
+ let out = tools::run(&call.name, &call_input, cwd);
2866
+ hooks::post_tool_use(&call.name, &call_input, &out.content, out.is_error, cwd);
2867
+ report::tool_result(&call.name, &out.content, out.is_error);
2868
+ trace_tool_result(&call.name, &out.content, out.is_error, "chat", tool_round);
2869
+ note_loop_result(
2870
+ &mut loop_guard,
2871
+ &call.name,
2872
+ &call_input,
2873
+ &out.content,
2874
+ out.is_error,
2875
+ &mut loop_summary,
2876
+ );
2877
+ results.push(ToolResult {
2878
+ id: call.id.clone(),
2879
+ content: out.content,
2880
+ is_error: out.is_error,
2881
+ });
2882
+ }
2883
+
2884
+ msgs.push(Msg::Assistant {
2885
+ text: assistant_tool_turn_text(reply.text, &reply.calls),
2886
+ calls: reply.calls,
2887
+ });
2888
+ msgs.push(Msg::Tool(results));
2889
+ if !report::is_json() {
2890
+ tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
2891
+ tui::poll_typeahead();
2892
+ }
2893
+ if let Some(loop_msg) = loop_summary {
2894
+ report::assistant(&loop_msg);
2895
+ return Ok(());
2896
+ }
2897
+ }
2898
+
2899
+ report::assistant(&format!(
2900
+ "I stopped after {MAX_CHAT_TOOL_ROUNDS} tool rounds without a final response."
2901
+ ));
2902
+ Ok(())
2903
+ }
2904
+
2905
+ fn trace_tool_call(name: &str, input: &serde_json::Value, phase: &str, depth: usize) {
2906
+ trace::record_visible(
2907
+ "tool_call",
2908
+ tools::preview(name, input),
2909
+ serde_json::json!({"tool": name, "input": input, "phase": phase, "depth": depth}),
2910
+ );
2911
+ }
2912
+
2913
+ fn trace_tool_result(name: &str, content: &str, is_error: bool, phase: &str, depth: usize) {
2914
+ trace::record_visible(
2915
+ "tool_result",
2916
+ format!("{name} {}", if is_error { "error" } else { "ok" }),
2917
+ serde_json::json!({"tool": name, "is_error": is_error, "content": content, "phase": phase, "depth": depth}),
2918
+ );
2919
+ }
2920
+
810
2921
  #[derive(Clone)]
811
2922
  pub enum ModeHint {
812
2923
  Build,
@@ -839,38 +2950,309 @@ mod tests {
839
2950
  assert!(role("ceo").system.contains("software engineer"));
840
2951
  }
841
2952
 
2953
+ #[test]
2954
+ fn text_json_tool_call_is_parsed_from_fenced_block() {
2955
+ let defs = tools::defs_for_context(true, 128_000);
2956
+ let reply = Reply {
2957
+ text: "```json\n{\"name\":\"start_server\",\"arguments\":{\"command\":\"npm start\",\"cwd\":\"/tmp/app\",\"port\":3000}}\n```".to_string(),
2958
+ calls: vec![],
2959
+ };
2960
+ let normalized = normalize_text_tool_calls(reply, &defs, "build the app");
2961
+ assert_eq!(normalized.text, "");
2962
+ assert_eq!(normalized.calls.len(), 1);
2963
+ assert_eq!(normalized.calls[0].name, "start_server");
2964
+ assert_eq!(normalized.calls[0].input["command"], "npm start");
2965
+ }
2966
+
2967
+ #[test]
2968
+ fn casual_text_json_mutating_tool_call_is_ignored() {
2969
+ let defs = tools::defs_for_context(true, 128_000);
2970
+ let reply = Reply {
2971
+ text: "```json\n{\"name\":\"start_server\",\"arguments\":{\"command\":\"npm start\",\"port\":3000}}\n```".to_string(),
2972
+ calls: vec![],
2973
+ };
2974
+ let normalized = normalize_text_tool_calls(reply, &defs, "hello");
2975
+ assert!(normalized.calls.is_empty());
2976
+ assert!(normalized.text.contains("ready"));
2977
+ assert!(!normalized.text.contains("start_server"));
2978
+ }
2979
+
2980
+ #[test]
2981
+ fn parse_plan_steps_keeps_only_list_items() {
2982
+ let steps = parse_plan_steps(
2983
+ "Intro prose.\n\
2984
+ 1. **Inspect the content of `hello.txt`**.\n\
2985
+ ```bash\n\
2986
+ write_file hello.txt nope\n\
2987
+ ```\n\
2988
+ 2. Step 2: Summarize the file.\n\
2989
+ Outro.",
2990
+ );
2991
+ assert_eq!(
2992
+ steps,
2993
+ vec![
2994
+ "Inspect the content of `hello.txt`.".to_string(),
2995
+ "Summarize the file.".to_string(),
2996
+ ]
2997
+ );
2998
+ }
2999
+
3000
+ #[test]
3001
+ fn parse_plan_steps_rejects_prose_only_plan() {
3002
+ let steps = parse_plan_steps(
3003
+ "The project directory does not exist. I will attempt to list files if you provide a path.",
3004
+ );
3005
+ assert!(steps.is_empty());
3006
+ }
3007
+
3008
+ #[test]
3009
+ fn plan_steps_must_be_actions_not_file_lists() {
3010
+ let files = vec![
3011
+ "`.env.example`".to_string(),
3012
+ "`README.md`".to_string(),
3013
+ "`harness/`".to_string(),
3014
+ ];
3015
+ assert!(!plan_steps_are_actionable(&files));
3016
+ let actions = vec![
3017
+ "Inspect the current workspace root.".to_string(),
3018
+ "List the top-level files.".to_string(),
3019
+ "Summarize the result.".to_string(),
3020
+ ];
3021
+ assert!(plan_steps_are_actionable(&actions));
3022
+ let meta = vec!["Write a clean exit plan with concrete steps.".to_string()];
3023
+ assert!(!plan_steps_are_actionable(&meta));
3024
+ }
3025
+
3026
+ #[test]
3027
+ fn exit_plan_tool_aliases_are_recognized() {
3028
+ assert!(is_exit_plan_tool("exit_plan"));
3029
+ assert!(is_exit_plan_tool("ExitPlanMode"));
3030
+ assert!(!is_exit_plan_tool("finish"));
3031
+ }
3032
+
3033
+ #[test]
3034
+ fn fallback_exit_plan_uses_readonly_steps_for_listing_tasks() {
3035
+ let input = fallback_exit_plan_input("inspect this project and list files; do not modify");
3036
+ let steps = input["steps"].as_array().unwrap();
3037
+ assert!(steps[0].as_str().unwrap().contains("read-only"));
3038
+ let plan = tools::run("exit_plan", &input, Path::new("/tmp"));
3039
+ assert!(!plan.is_error, "{}", plan.content);
3040
+ assert!(plan_steps_are_actionable(&parse_plan_steps(&plan.content)));
3041
+ }
3042
+
3043
+ #[test]
3044
+ fn fallback_exit_plan_uses_task_specific_steps_for_file_creation() {
3045
+ let input = fallback_exit_plan_input(
3046
+ "create scratch/exitplan-smoke.txt containing exit plan smoke",
3047
+ );
3048
+ let plan = tools::run("exit_plan", &input, Path::new("/tmp"));
3049
+ assert!(!plan.is_error, "{}", plan.content);
3050
+ assert!(plan.content.contains("Create scratch/exitplan-smoke.txt"));
3051
+ assert!(!plan.content.contains("Implement the requested change"));
3052
+ }
3053
+
3054
+ #[test]
3055
+ fn approved_plan_handoff_is_execution_directive() {
3056
+ let full = approved_plan_build_task(
3057
+ "build me a canvas game",
3058
+ "1. Create a single HTML artifact.\n2. Verify it opens.",
3059
+ );
3060
+ assert!(full.contains("Follow this approved plan:"));
3061
+ assert!(full.contains("BUILD EXECUTION DIRECTIVE"));
3062
+ assert!(full.contains("Do not re-plan"));
3063
+ assert!(full.contains("Execute the approved steps now using tools"));
3064
+ assert!(full.contains("finish tool"));
3065
+ }
3066
+
3067
+ #[test]
3068
+ fn recovery_task_text_strips_approved_plan_directive() {
3069
+ let full = approved_plan_build_task(
3070
+ "create scratch/exitplan-smoke.txt containing exit plan smoke",
3071
+ "1. Write the file.",
3072
+ );
3073
+ assert_eq!(
3074
+ recovery_task_text(&full),
3075
+ "create scratch/exitplan-smoke.txt containing exit plan smoke"
3076
+ );
3077
+ }
3078
+
3079
+ #[test]
3080
+ fn auto_create_file_input_parses_simple_create_request() {
3081
+ let input =
3082
+ auto_create_file_input("create scratch/exitplan-smoke.txt containing exit plan smoke")
3083
+ .unwrap();
3084
+ assert_eq!(input["path"], "scratch/exitplan-smoke.txt");
3085
+ assert_eq!(input["content"], "exit plan smoke");
3086
+ let with_article = auto_create_file_input(
3087
+ "Create a scratch/exitplan-smoke.txt containing exit plan smoke",
3088
+ )
3089
+ .unwrap();
3090
+ assert_eq!(with_article["path"], "scratch/exitplan-smoke.txt");
3091
+ let with_plan_context = auto_create_file_input(
3092
+ "create scratch/exitplan-smoke.txt containing exit plan smoke\n\nFollow this approved plan:\n1. Write the file.",
3093
+ )
3094
+ .unwrap();
3095
+ assert_eq!(with_plan_context["content"], "exit plan smoke");
3096
+ assert!(auto_create_file_input("create a thing").is_none());
3097
+ }
3098
+
3099
+ #[test]
3100
+ fn write_file_placeholder_path_is_repaired_from_create_task() {
3101
+ let repaired = repair_placeholder_tool_input(
3102
+ "write_file",
3103
+ &json!({"path": "~", "content": "exit plan smoke\n- Create scratch/exitplan-smoke.txt"}),
3104
+ Path::new("/tmp/example-project"),
3105
+ "create scratch/exitplan-smoke.txt containing exit plan smoke",
3106
+ )
3107
+ .unwrap();
3108
+ assert_eq!(repaired["path"], "scratch/exitplan-smoke.txt");
3109
+ assert_eq!(repaired["content"], "exit plan smoke");
3110
+ }
3111
+
3112
+ #[test]
3113
+ fn write_file_plan_contaminated_content_is_repaired_from_create_task() {
3114
+ let repaired = repair_placeholder_tool_input(
3115
+ "write",
3116
+ &json!({"path": "scratch/exitplan-smoke.txt", "content": "exit plan smoke\n\n1. Create scratch/exitplan-smoke.txt"}),
3117
+ Path::new("/tmp/example-project"),
3118
+ "create scratch/exitplan-smoke.txt containing exit plan smoke",
3119
+ )
3120
+ .unwrap();
3121
+ assert_eq!(repaired["path"], "scratch/exitplan-smoke.txt");
3122
+ assert_eq!(repaired["content"], "exit plan smoke");
3123
+ }
3124
+
3125
+ #[test]
3126
+ fn placeholder_project_path_is_repaired_for_discovery_tools() {
3127
+ let cwd = Path::new("/tmp/example-project");
3128
+ let repaired = repair_placeholder_tool_input(
3129
+ "list",
3130
+ &json!({"path": "/path/to/your/project"}),
3131
+ cwd,
3132
+ "inspect this project",
3133
+ )
3134
+ .unwrap();
3135
+ assert_eq!(repaired["path"], "/tmp/example-project");
3136
+ assert!(repair_placeholder_tool_input(
3137
+ "read_file",
3138
+ &json!({"path": "your-project-file.txt"}),
3139
+ cwd,
3140
+ "inspect this project"
3141
+ )
3142
+ .is_none());
3143
+ }
3144
+
3145
+ #[test]
3146
+ fn filesystem_root_is_repaired_for_current_workspace_discovery() {
3147
+ let cwd = Path::new("/tmp/example-project");
3148
+ let repaired = repair_placeholder_tool_input(
3149
+ "list",
3150
+ &json!({"path": "/"}),
3151
+ cwd,
3152
+ "inspect this project and list the top-level files",
3153
+ )
3154
+ .unwrap();
3155
+ assert_eq!(repaired["path"], "/tmp/example-project");
3156
+ }
3157
+
3158
+ #[test]
3159
+ fn filesystem_root_is_not_repaired_when_user_asks_for_root() {
3160
+ let cwd = Path::new("/tmp/example-project");
3161
+ assert!(repair_placeholder_tool_input(
3162
+ "list",
3163
+ &json!({"path": "/"}),
3164
+ cwd,
3165
+ "inspect the filesystem root / and list its top-level files",
3166
+ )
3167
+ .is_none());
3168
+ }
3169
+
3170
+ #[test]
3171
+ fn auto_static_artifact_extracts_html_response() {
3172
+ let input = auto_static_artifact_input(
3173
+ "build me a canvas game",
3174
+ "HTML:\n```html\n<!doctype html><html><head><title>Orbit</title></head><body><canvas></canvas><script>requestAnimationFrame(()=>{})</script></body></html>\n```",
3175
+ )
3176
+ .unwrap();
3177
+ assert_eq!(input["title"], "Orbit");
3178
+ assert!(input["contents"].as_str().unwrap().contains("<canvas>"));
3179
+ }
3180
+
3181
+ #[test]
3182
+ fn auto_static_artifact_ignores_non_static_tasks() {
3183
+ assert!(auto_static_artifact_input(
3184
+ "explain this code",
3185
+ "<!doctype html><html><body></body></html>"
3186
+ )
3187
+ .is_none());
3188
+ }
3189
+
3190
+ #[test]
3191
+ fn static_artifact_recovery_prompt_tells_model_to_build() {
3192
+ let prompt = static_artifact_recovery_prompt("build me a canvas game");
3193
+ assert!(prompt.contains("BUILD task"));
3194
+ assert!(prompt.contains("reasonable defaults"));
3195
+ assert!(prompt.contains("Artifact/publish_artifact"));
3196
+ assert!(prompt.contains("self-contained HTML"));
3197
+ }
3198
+
842
3199
  #[test]
843
3200
  fn gate_auto_allows_ordinary_mutation() {
844
3201
  let cwd = Path::new("/proj");
845
- let r = gate(Permission::Auto, "write_file", &json!({"path": "a.txt", "content": "x"}), cwd);
3202
+ let r = gate(
3203
+ Permission::Auto,
3204
+ "write_file",
3205
+ &json!({"path": "a.txt", "content": "x"}),
3206
+ cwd,
3207
+ );
846
3208
  assert!(r.is_none());
847
3209
  }
848
3210
 
849
3211
  #[test]
850
3212
  fn gate_auto_still_confirms_sensitive_path() {
851
3213
  let cwd = Path::new("/proj");
852
- let r = gate(Permission::Auto, "read_file", &json!({"path": "/proj/.env"}), cwd);
3214
+ let r = gate(
3215
+ Permission::Auto,
3216
+ "read_file",
3217
+ &json!({"path": "/proj/.env"}),
3218
+ cwd,
3219
+ );
853
3220
  assert!(r.is_some());
854
3221
  }
855
3222
 
856
3223
  #[test]
857
3224
  fn gate_auto_confirms_catastrophic_command() {
858
3225
  let cwd = Path::new("/proj");
859
- let r = gate(Permission::Auto, "run_command", &json!({"command": "rm -rf /"}), cwd);
3226
+ let r = gate(
3227
+ Permission::Auto,
3228
+ "run_command",
3229
+ &json!({"command": "rm -rf /"}),
3230
+ cwd,
3231
+ );
860
3232
  assert!(r.is_some());
861
3233
  }
862
3234
 
863
3235
  #[test]
864
3236
  fn gate_auto_allows_safe_command() {
865
3237
  let cwd = Path::new("/proj");
866
- let r = gate(Permission::Auto, "run_command", &json!({"command": "ls"}), cwd);
3238
+ let r = gate(
3239
+ Permission::Auto,
3240
+ "run_command",
3241
+ &json!({"command": "ls"}),
3242
+ cwd,
3243
+ );
867
3244
  assert!(r.is_none());
868
3245
  }
869
3246
 
870
3247
  #[test]
871
3248
  fn gate_readonly_blocks_mutation() {
872
3249
  let cwd = Path::new("/proj");
873
- let r = gate(Permission::ReadOnly, "write_file", &json!({"path": "a", "content": "x"}), cwd);
3250
+ let r = gate(
3251
+ Permission::ReadOnly,
3252
+ "write_file",
3253
+ &json!({"path": "a", "content": "x"}),
3254
+ cwd,
3255
+ );
874
3256
  assert!(r.unwrap().contains("read-only"));
875
3257
  }
876
3258
 
@@ -878,7 +3260,12 @@ mod tests {
878
3260
  fn gate_readonly_allows_reads_everywhere() {
879
3261
  // Reads outside CWD are now allowed — full filesystem access.
880
3262
  let cwd = Path::new("/proj/work");
881
- let r = gate(Permission::ReadOnly, "read_file", &json!({"path": "/etc/passwd"}), cwd);
3263
+ let r = gate(
3264
+ Permission::ReadOnly,
3265
+ "read_file",
3266
+ &json!({"path": "/etc/passwd"}),
3267
+ cwd,
3268
+ );
882
3269
  assert!(r.is_none());
883
3270
  }
884
3271
 
@@ -886,14 +3273,24 @@ mod tests {
886
3273
  fn gate_ask_allows_out_of_cwd_read() {
887
3274
  // Full filesystem read access — no longer blocked in Ask mode.
888
3275
  let cwd = Path::new("/proj");
889
- let r = gate(Permission::Ask, "read_file", &json!({"path": "/home/user/docs/README.md"}), cwd);
3276
+ let r = gate(
3277
+ Permission::Ask,
3278
+ "read_file",
3279
+ &json!({"path": "/home/user/docs/README.md"}),
3280
+ cwd,
3281
+ );
890
3282
  assert!(r.is_none());
891
3283
  }
892
3284
 
893
3285
  #[test]
894
3286
  fn gate_ask_prompts_for_mutation() {
895
3287
  let cwd = Path::new("/proj");
896
- let r = gate(Permission::Ask, "write_file", &json!({"path": "a", "content": "x"}), cwd);
3288
+ let r = gate(
3289
+ Permission::Ask,
3290
+ "write_file",
3291
+ &json!({"path": "a", "content": "x"}),
3292
+ cwd,
3293
+ );
897
3294
  assert!(r.is_some()); // non-terminal → denied
898
3295
  }
899
3296
 
@@ -901,8 +3298,18 @@ mod tests {
901
3298
  fn gate_ask_allows_default_allowed_commands() {
902
3299
  let cwd = Path::new("/proj");
903
3300
  // git, cargo, npm are in the default allowed_commands list
904
- for cmd in &["git status", "cargo test", "npm install", "git commit -m 'x'"] {
905
- let r = gate(Permission::Ask, "run_command", &json!({"command": cmd}), cwd);
3301
+ for cmd in &[
3302
+ "git status",
3303
+ "cargo test",
3304
+ "npm install",
3305
+ "git commit -m 'x'",
3306
+ ] {
3307
+ let r = gate(
3308
+ Permission::Ask,
3309
+ "run_command",
3310
+ &json!({"command": cmd}),
3311
+ cwd,
3312
+ );
906
3313
  assert!(r.is_none(), "expected {cmd} to be auto-allowed in Ask mode");
907
3314
  }
908
3315
  }
@@ -910,7 +3317,12 @@ mod tests {
910
3317
  #[test]
911
3318
  fn gate_ask_still_prompts_for_unknown_command() {
912
3319
  let cwd = Path::new("/proj");
913
- let r = gate(Permission::Ask, "run_command", &json!({"command": "mycustombinary --flag"}), cwd);
3320
+ let r = gate(
3321
+ Permission::Ask,
3322
+ "run_command",
3323
+ &json!({"command": "mycustombinary --flag"}),
3324
+ cwd,
3325
+ );
914
3326
  assert!(r.is_some()); // not in allowed list → denied (non-terminal)
915
3327
  }
916
3328
 
@@ -962,4 +3374,11 @@ mod tests {
962
3374
  }];
963
3375
  assert!(structural_summary(&msgs).contains("run: ls"));
964
3376
  }
3377
+
3378
+ #[test]
3379
+ fn test_session_allowlist() {
3380
+ assert!(!super::is_session_allowed_tool("custom_test_tool"));
3381
+ super::add_session_allowed_tool("custom_test_tool");
3382
+ assert!(super::is_session_allowed_tool("custom_test_tool"));
3383
+ }
965
3384
  }