clouds-coder 2026.4.2.1__tar.gz → 2026.4.6__tar.gz

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.
@@ -192,7 +192,7 @@ REPEATED_TOOL_LOOP_THRESHOLD = 2
192
192
  BASH_READ_LOOP_THRESHOLD = 3
193
193
  HARD_BREAK_TOOL_ERROR_THRESHOLD = 20
194
194
  HARD_BREAK_RECOVERY_ROUND_THRESHOLD = 3
195
- FUSED_FAULT_BREAK_THRESHOLD = 3
195
+ FUSED_FAULT_BREAK_THRESHOLD = 15
196
196
  STALL_SEVERITY_ESCALATION_THRESHOLD = 5
197
197
  STALL_SEVERITY_WEIGHT_BASH_READ_LOOP = 2
198
198
  STALL_SEVERITY_WEIGHT_REPEATED_TOOL = 3
@@ -216,6 +216,22 @@ DEFAULT_TIMEOUT_SECONDS = max(
216
216
  ),
217
217
  )
218
218
  DEFAULT_REQUEST_TIMEOUT = DEFAULT_TIMEOUT_SECONDS
219
+ # Patterns that indicate the shell process is waiting for interactive confirmation.
220
+ # Checked against the tail of combined stdout+stderr (lowercased bytes).
221
+ _SHELL_AUTO_CONFIRM_PATTERNS: tuple[bytes, ...] = (
222
+ b"ok to proceed? (y)",
223
+ b"proceed? (y)",
224
+ b"? (y)",
225
+ b"[y/n]",
226
+ b"[y/n]:",
227
+ b"[yes/no]",
228
+ b"(y/n)",
229
+ b"(yes/no)",
230
+ b"continue? (y/n)",
231
+ b"do you want to continue",
232
+ b"press enter to continue",
233
+ b"enter to continue",
234
+ )
219
235
  MIN_SHELL_COMMAND_TIMEOUT_SECONDS = 10
220
236
  MAX_SHELL_COMMAND_TIMEOUT_SECONDS = 86_400
221
237
  DEFAULT_SHELL_COMMAND_TIMEOUT_SECONDS = max(
@@ -247,7 +263,7 @@ WATCHDOG_CONTEXT_NEAR_RATIO = 0.92
247
263
  WATCHDOG_MAX_DECOMPOSE_STEPS = 12
248
264
  WATCHDOG_STEP_MAX_ATTEMPTS = 2
249
265
  EMPTY_ACTION_MIN_CONTENT_CHARS = 5
250
- EMPTY_ACTION_WAKEUP_RETRY_LIMIT = 2
266
+ EMPTY_ACTION_WAKEUP_RETRY_LIMIT = 5
251
267
  THINKING_BUDGET_FORCE_RATIO = 0.85
252
268
  # --- Tool timeout configuration ---
253
269
  _TOOL_TIMEOUT_MAP = {
@@ -398,7 +414,13 @@ BLACKBOARD_STATUSES = (
398
414
  "COMPLETED",
399
415
  "PAUSED",
400
416
  )
401
- TASK_COMPLEXITY_LEVELS = ("simple", "complex")
417
+ TASK_COMPLEXITY_LEVELS = ("simple", "moderate", "complex", "expert")
418
+ TASK_COMPLEXITY_RANKS = {
419
+ "simple": 1,
420
+ "moderate": 2,
421
+ "complex": 3,
422
+ "expert": 4,
423
+ }
402
424
  TASK_PROFILE_TYPES = (
403
425
  "simple_qa",
404
426
  "simple_code",
@@ -435,7 +457,7 @@ TASK_LEVEL_POLICIES: dict[int, dict] = {
435
457
  "assigned_expert": "developer",
436
458
  "round_budget": 16,
437
459
  "requires_user_confirmation": False,
438
- "complexity": "simple",
460
+ "complexity": "moderate",
439
461
  },
440
462
  4: {
441
463
  "name": "complex_collaboration",
@@ -453,7 +475,7 @@ TASK_LEVEL_POLICIES: dict[int, dict] = {
453
475
  "assigned_expert": "explorer",
454
476
  "round_budget": 0, # 0 means unlimited by tier budget (still guarded by global safeguards).
455
477
  "requires_user_confirmation": True,
456
- "complexity": "complex",
478
+ "complexity": "expert",
457
479
  },
458
480
  }
459
481
  MANAGER_ROUTE_TARGETS = ("explorer", "developer", "reviewer", "finish")
@@ -520,7 +542,7 @@ TASK_PHASE_ROUTING = {
520
542
  COMPLEXITY_KEYWORDS = (
521
543
  "简单", "复杂", "难", "容易", "快速", "详细", "深入",
522
544
  "l1", "l2", "l3", "l4", "l5",
523
- "simple", "complex", "easy", "hard", "difficult",
545
+ "simple", "moderate", "medium", "complex", "expert", "easy", "hard", "difficult",
524
546
  "thorough", "quick", "fast", "lightweight", "heavy",
525
547
  )
526
548
  USER_COMPLEXITY_SIMPLE_TOKENS = (
@@ -528,12 +550,23 @@ USER_COMPLEXITY_SIMPLE_TOKENS = (
528
550
  "low", "simple", "easy", "quick", "fast", "lightweight", "basic", "minimal",
529
551
  "l1", "l2",
530
552
  )
553
+ USER_COMPLEXITY_MODERATE_TOKENS = (
554
+ "中等复杂度", "中等难度", "适中", "平衡", "标准", "普通", "常规",
555
+ "medium", "mid", "moderate", "balanced", "standard", "normal",
556
+ "l3",
557
+ )
531
558
  USER_COMPLEXITY_COMPLEX_TOKENS = (
532
- "复杂", "深入", "详细", "高复杂度", "高难度", "中等复杂度", "中高复杂度",
533
- "medium", "mid", "high", "complex", "hard", "difficult", "thorough", "detailed", "deep", "heavy",
534
- "l3", "l4", "l5",
559
+ "复杂", "深入", "详细", "高复杂度", "高难度", "中高复杂度",
560
+ "high", "complex", "hard", "difficult", "thorough", "detailed", "deep", "heavy",
561
+ "l4",
562
+ )
563
+ USER_COMPLEXITY_EXPERT_TOKENS = (
564
+ "专家级", "系統級", "系统级", "生产级", "企業級", "企业级", "高风险", "超高复杂度",
565
+ "expert", "advanced", "system-level", "production-ready", "enterprise", "mission-critical",
566
+ "l5",
535
567
  )
536
568
  PLAN_MODE_EXPLORER_MAX_ROUNDS = 8
569
+ PLAN_MODE_SYNTHESIS_MAX_ATTEMPTS = 3
537
570
  # Reviewer debug mode
538
571
  REVIEWER_DEBUG_MODE_MAX_ROUNDS = 6
539
572
  REVIEWER_DEBUG_TOOL_ALLOWLIST = {
@@ -543,7 +576,7 @@ REVIEWER_DEBUG_TOOL_ALLOWLIST = {
543
576
  }
544
577
  EXPLORER_STALL_THRESHOLD = 3 # consecutive same-target delegations before forced switch
545
578
  DEVELOPER_EDIT_STALL_THRESHOLD = 3 # consecutive edit_file failures on same file before forced strategy change
546
- PLAN_MODE_MANAGER_SYNTHESIS_MAX_TOKENS = 6144
579
+ PLAN_MODE_MANAGER_SYNTHESIS_MAX_TOKENS = 8192
547
580
  PLAN_MODE_MAX_OPTIONS = 3
548
581
  PLAN_FILE_RELATIVE_PATH = ".clouds_coder/plan.md"
549
582
  PLAN_BUBBLE_MAX_CHARS = 12_000
@@ -3086,19 +3119,243 @@ def decompress_text_blob(blob_b64: str) -> str:
3086
3119
  except Exception:
3087
3120
  return ""
3088
3121
 
3122
+ def normalize_embedded_newlines(text: object) -> str:
3123
+ s = str(text or "")
3124
+ if not s:
3125
+ return ""
3126
+ s = s.replace("\u2028", "\n").replace("\u2029", "\n")
3127
+ s = s.replace("\r\n", "\n").replace("\r", "\n")
3128
+ if "\\n" in s or "\\r" in s or "\\t" in s:
3129
+ s = s.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "\n").replace("\\t", "\t")
3130
+ return s
3131
+
3132
+
3133
+ def _map_todo_status_token(token: str) -> str:
3134
+ raw = str(token or "").strip().lower().replace("_", " ").replace("-", " ")
3135
+ raw = re.sub(r"\s+", " ", raw)
3136
+ return {
3137
+ "pending": "pending",
3138
+ "待处理": "pending",
3139
+ "待處理": "pending",
3140
+ "未着手": "pending",
3141
+ "in progress": "in_progress",
3142
+ "进行中": "in_progress",
3143
+ "進行中": "in_progress",
3144
+ "completed": "completed",
3145
+ "已完成": "completed",
3146
+ "完了": "completed",
3147
+ "blocked": "pending",
3148
+ }.get(raw, "")
3149
+
3150
+
3151
+ def split_todo_status_text(text: object) -> tuple[str, str]:
3152
+ probe = normalize_embedded_newlines(text).strip()
3153
+ if not probe:
3154
+ return "", ""
3155
+ status = ""
3156
+ marker_prefix = r"(?:[-*•>]+\s*)?"
3157
+ for _ in range(4):
3158
+ before = probe
3159
+ probe = re.sub(r"^\s+", "", probe)
3160
+ matched = False
3161
+ for row_status, pattern in (
3162
+ (
3163
+ "completed",
3164
+ rf"^(?:{marker_prefix})(?:"
3165
+ rf"\[x\]\s*"
3166
+ rf")",
3167
+ ),
3168
+ (
3169
+ "in_progress",
3170
+ rf"^(?:{marker_prefix})(?:"
3171
+ rf"\[>\]\s*"
3172
+ rf")",
3173
+ ),
3174
+ (
3175
+ "pending",
3176
+ rf"^(?:{marker_prefix})(?:"
3177
+ rf"\[\s*\]\s*"
3178
+ rf")",
3179
+ ),
3180
+ ):
3181
+ m = re.match(pattern, probe, flags=re.IGNORECASE)
3182
+ if not m:
3183
+ continue
3184
+ status = row_status
3185
+ probe = probe[m.end():].strip()
3186
+ matched = True
3187
+ break
3188
+ if matched:
3189
+ continue
3190
+ m = re.match(
3191
+ rf"^(?:{marker_prefix})"
3192
+ rf"(pending|in[_\-\s]?progress|completed|blocked|"
3193
+ rf"待处理|待處理|未着手|进行中|進行中|已完成|完了)"
3194
+ rf"\s*[::\-\]]\s*",
3195
+ probe,
3196
+ flags=re.IGNORECASE,
3197
+ )
3198
+ if m:
3199
+ mapped = _map_todo_status_token(str(m.group(1) or ""))
3200
+ if mapped:
3201
+ status = mapped
3202
+ probe = probe[m.end():].strip()
3203
+ continue
3204
+ if probe == before:
3205
+ break
3206
+ return status, probe.strip()
3207
+
3208
+
3209
+ def extract_todo_rows_from_text(
3210
+ text: object,
3211
+ *,
3212
+ default_parent_step_id: str = "",
3213
+ limit: int = 12,
3214
+ ) -> list[dict]:
3215
+ src = normalize_embedded_newlines(text)
3216
+ if not src.strip():
3217
+ return []
3218
+ out: list[dict] = []
3219
+ seen: set[tuple[str, str, str]] = set()
3220
+ capped = max(1, min(40, int(limit or 12)))
3221
+ parent_step_id = trim(str(default_parent_step_id or "").strip(), 20)
3222
+ for raw_line in src.splitlines():
3223
+ line = trim(str(raw_line or "").strip(), 600)
3224
+ if not line:
3225
+ continue
3226
+ variants: list[str] = []
3227
+ for candidate in (
3228
+ line,
3229
+ re.sub(r"^\s*(?:[-*•>]+\s*)+", "", line).strip(),
3230
+ re.sub(r"^\s*\*\*([^*]+)\*\*\s*([::])\s*", r"\1\2 ", line).strip(),
3231
+ re.sub(r"^\s*(?:[-*•>]+\s*)*\*\*([^*]+)\*\*\s*([::])\s*", r"\1\2 ", line).strip(),
3232
+ ):
3233
+ candidate = trim(str(candidate or "").strip(), 600)
3234
+ if candidate and candidate not in variants:
3235
+ variants.append(candidate)
3236
+ matched = False
3237
+ for candidate in variants:
3238
+ status, content = split_todo_status_text(candidate)
3239
+ if not status or not content:
3240
+ continue
3241
+ cleaned = normalize_work_text(content, status) or content
3242
+ cleaned = trim(cleaned.strip(), 400)
3243
+ if not cleaned:
3244
+ continue
3245
+ low = cleaned.lower()
3246
+ if low in {
3247
+ "todo",
3248
+ "todos",
3249
+ "task",
3250
+ "tasks",
3251
+ "subtask",
3252
+ "subtasks",
3253
+ "待办",
3254
+ "待辦",
3255
+ "子任务",
3256
+ "子任務",
3257
+ }:
3258
+ continue
3259
+ row = {"content": cleaned, "status": status}
3260
+ if parent_step_id:
3261
+ row["parent_step_id"] = parent_step_id
3262
+ identity = (
3263
+ status,
3264
+ normalize_work_text(cleaned, status).strip().lower(),
3265
+ parent_step_id,
3266
+ )
3267
+ if identity in seen:
3268
+ matched = True
3269
+ break
3270
+ seen.add(identity)
3271
+ out.append(row)
3272
+ matched = True
3273
+ break
3274
+ if matched and len(out) >= capped:
3275
+ break
3276
+ return out
3277
+
3278
+
3279
+ def infer_todo_status_from_text(text: object, default: str = "pending") -> str:
3280
+ status, content = split_todo_status_text(text)
3281
+ if not content and not status:
3282
+ return default
3283
+ if status:
3284
+ return status
3285
+ return default
3286
+
3287
+
3288
+ def split_structured_todo_content(text: object, limit: int = 7) -> list[str]:
3289
+ src = normalize_embedded_newlines(text).strip()
3290
+ if not src:
3291
+ return []
3292
+ lines = [trim(str(line or "").strip(), 500) for line in src.split("\n")]
3293
+ lines = [line for line in lines if line]
3294
+ if len(lines) <= 1:
3295
+ return [src]
3296
+ major_re = re.compile(r"^(\d+)\.\s+(.+)$")
3297
+ sub_re = re.compile(r"^(\d+)\.(\d+)\s+(.+)$")
3298
+ bullet_re = re.compile(r"^(?:[-*•]\s+)(.+)$")
3299
+ header_major = ""
3300
+ m0 = major_re.match(lines[0])
3301
+ if m0:
3302
+ header_major = str(m0.group(1) or "")
3303
+ picked: list[str] = []
3304
+ for idx, line in enumerate(lines):
3305
+ if idx == 0 and header_major:
3306
+ continue
3307
+ m_sub = sub_re.match(line)
3308
+ if m_sub:
3309
+ major = str(m_sub.group(1) or "")
3310
+ if header_major and major != header_major:
3311
+ if picked:
3312
+ break
3313
+ continue
3314
+ picked.append(f"{major}.{m_sub.group(2)} {trim(str(m_sub.group(3) or '').strip(), 420)}".strip())
3315
+ continue
3316
+ m_bullet = bullet_re.match(line)
3317
+ if m_bullet and (header_major or picked):
3318
+ picked.append(trim(str(m_bullet.group(1) or "").strip(), 420))
3319
+ continue
3320
+ if picked and re.match(r"^\d+\.\s+", line):
3321
+ break
3322
+ if not picked:
3323
+ for line in lines:
3324
+ m_sub = sub_re.match(line)
3325
+ if m_sub:
3326
+ picked.append(f"{m_sub.group(1)}.{m_sub.group(2)} {trim(str(m_sub.group(3) or '').strip(), 420)}".strip())
3327
+ if len(picked) >= max(1, int(limit or 7)):
3328
+ break
3329
+ if not picked:
3330
+ return [src]
3331
+ out: list[str] = []
3332
+ seen: set[str] = set()
3333
+ for line in picked:
3334
+ key = re.sub(r"\s+", " ", str(line or "").strip()).lower()
3335
+ if not key or key in seen:
3336
+ continue
3337
+ seen.add(key)
3338
+ out.append(line)
3339
+ if len(out) >= max(1, int(limit or 7)):
3340
+ break
3341
+ return out or [src]
3342
+
3343
+
3089
3344
  def normalize_work_text(text: object, status: str = "") -> str:
3090
- s = re.sub(r"\s+", " ", str(text or "")).strip()
3345
+ parsed_status, parsed_content = split_todo_status_text(text)
3346
+ s = re.sub(r"\s+", " ", parsed_content or normalize_embedded_newlines(text)).strip()
3091
3347
  if not s:
3092
3348
  return ""
3093
- s = re.sub(r"^\[[ x>\-]\]\s*", "", s, flags=re.IGNORECASE)
3094
3349
  s = re.sub(
3095
- r"^(pending|in[_\-\s]?progress|completed|done|blocked)\s*[·:\-\]]\s*",
3350
+ r"^(pending|todo|in[_\-\s]?progress|doing|working|completed|done|finished|blocked|"
3351
+ r"待处理|待處理|未着手|进行中|進行中|作業中|已完成|完成|完了)\s*[·::\-\]]\s*",
3096
3352
  "",
3097
3353
  s,
3098
3354
  flags=re.IGNORECASE,
3099
3355
  )
3100
- if status:
3101
- status_pattern = re.escape(status).replace("_", r"[_\-\s]?")
3356
+ status_key = _map_todo_status_token(status) or _map_todo_status_token(parsed_status) or str(status or "").strip().lower()
3357
+ if status_key:
3358
+ status_pattern = re.escape(status_key).replace("_", r"[_\-\s]?")
3102
3359
  s = re.sub(
3103
3360
  rf"\s*[—-]\s*{status_pattern}\s*$",
3104
3361
  "",
@@ -3529,6 +3786,12 @@ def infer_user_complexity_value(text: str) -> str:
3529
3786
  low = strip_thinking_content(str(text or "")).strip().lower()
3530
3787
  if not low:
3531
3788
  return ""
3789
+ for token in USER_COMPLEXITY_EXPERT_TOKENS:
3790
+ if re.search(rf"(?<![a-z0-9]){re.escape(token)}(?![a-z0-9])", low) if token.isascii() else token in low:
3791
+ return "expert"
3792
+ for token in USER_COMPLEXITY_MODERATE_TOKENS:
3793
+ if re.search(rf"(?<![a-z0-9]){re.escape(token)}(?![a-z0-9])", low) if token.isascii() else token in low:
3794
+ return "moderate"
3532
3795
  for token in USER_COMPLEXITY_SIMPLE_TOKENS:
3533
3796
  if re.search(rf"(?<![a-z0-9]){re.escape(token)}(?![a-z0-9])", low) if token.isascii() else token in low:
3534
3797
  return "simple"
@@ -3537,6 +3800,53 @@ def infer_user_complexity_value(text: str) -> str:
3537
3800
  return "complex"
3538
3801
  return ""
3539
3802
 
3803
+ def normalize_task_complexity(raw: object, default: str = "simple") -> str:
3804
+ value = str(raw or "").strip().lower()
3805
+ aliases = {
3806
+ "simple": "simple",
3807
+ "low": "simple",
3808
+ "basic": "simple",
3809
+ "minimal": "simple",
3810
+ "moderate": "moderate",
3811
+ "medium": "moderate",
3812
+ "mid": "moderate",
3813
+ "balanced": "moderate",
3814
+ "standard": "moderate",
3815
+ "complex": "complex",
3816
+ "high": "complex",
3817
+ "hard": "complex",
3818
+ "difficult": "complex",
3819
+ "expert": "expert",
3820
+ "advanced": "expert",
3821
+ "system": "expert",
3822
+ "system_level": "expert",
3823
+ "production": "expert",
3824
+ }
3825
+ normalized = aliases.get(value, value)
3826
+ if normalized in TASK_COMPLEXITY_LEVELS:
3827
+ return normalized
3828
+ fallback = str(default or "").strip().lower()
3829
+ if not fallback:
3830
+ return ""
3831
+ return fallback if fallback in TASK_COMPLEXITY_LEVELS else "simple"
3832
+
3833
+ def task_complexity_rank(raw: object, default: str = "simple") -> int:
3834
+ return int(TASK_COMPLEXITY_RANKS.get(normalize_task_complexity(raw, default=default), 1))
3835
+
3836
+ def task_complexity_at_least(raw: object, threshold: str) -> bool:
3837
+ return task_complexity_rank(raw) >= task_complexity_rank(threshold)
3838
+
3839
+ def max_task_complexity(*values: object, default: str = "simple") -> str:
3840
+ best = normalize_task_complexity(default, default=default)
3841
+ best_rank = task_complexity_rank(best, default=default)
3842
+ for value in values:
3843
+ cur = normalize_task_complexity(value, default=default)
3844
+ cur_rank = task_complexity_rank(cur, default=default)
3845
+ if cur_rank > best_rank:
3846
+ best = cur
3847
+ best_rank = cur_rank
3848
+ return best
3849
+
3540
3850
  def normalize_openai_compat_provider_name(raw: str) -> str:
3541
3851
  value = str(raw or "").strip().lower().replace("-", "_")
3542
3852
  aliases = {
@@ -5336,6 +5646,31 @@ class TodoManager:
5336
5646
  def update(self, items: list[dict]) -> str:
5337
5647
  if not isinstance(items, list):
5338
5648
  raise ValueError("items must be array")
5649
+ expanded_items: list[dict] = []
5650
+ for item in items:
5651
+ if isinstance(item, str):
5652
+ raw = {"content": item, "status": "pending"}
5653
+ elif isinstance(item, dict):
5654
+ raw = dict(item)
5655
+ else:
5656
+ try:
5657
+ raw = {"content": str(item).strip(), "status": "pending"}
5658
+ except Exception:
5659
+ continue
5660
+ raw_content = str(raw.get("content", raw.get("text", raw.get("title", "")))).strip()
5661
+ split_rows = split_structured_todo_content(raw_content, limit=7)
5662
+ if len(split_rows) <= 1:
5663
+ expanded_items.append(raw)
5664
+ continue
5665
+ base_status = str(raw.get("status", raw.get("state", "pending")) or "pending").strip().lower()
5666
+ for split_idx, split_content in enumerate(split_rows):
5667
+ split_raw = dict(raw)
5668
+ split_raw["content"] = split_content
5669
+ split_raw["status"] = infer_todo_status_from_text(
5670
+ split_content,
5671
+ default=(base_status if split_idx == 0 else "pending"),
5672
+ )
5673
+ expanded_items.append(split_raw)
5339
5674
  validated = []
5340
5675
  # Plan-step items (bb:proj: key) keep a single in_progress slot.
5341
5676
  # Worker/non-plan items allow one in_progress per owner so sync-mode agents
@@ -5351,18 +5686,10 @@ class TodoManager:
5351
5686
  "finish": "completed",
5352
5687
  "finished": "completed",
5353
5688
  }
5354
- for idx, item in enumerate(items):
5355
- if isinstance(item, str):
5356
- raw = {"content": item, "status": "pending"}
5357
- elif isinstance(item, dict):
5358
- raw = item
5359
- else:
5360
- # Tolerant: convert to string instead of raising
5361
- try:
5362
- raw = {"content": str(item).strip(), "status": "pending"}
5363
- except Exception:
5364
- continue # Skip unparseable items
5689
+ for idx, item in enumerate(expanded_items):
5690
+ raw = item if isinstance(item, dict) else {"content": str(item or "").strip(), "status": "pending"}
5365
5691
  raw_content = str(raw.get("content", raw.get("text", raw.get("title", "")))).strip()
5692
+ inferred_status = infer_todo_status_from_text(raw_content, default="")
5366
5693
  content = normalize_work_text(raw_content)
5367
5694
  if not content:
5368
5695
  content = raw_content
@@ -5370,8 +5697,10 @@ class TodoManager:
5370
5697
  continue # Skip empty items instead of raising
5371
5698
  raw_status = str(raw.get("status", raw.get("state", "pending"))).strip().lower()
5372
5699
  status = status_alias.get(raw_status, raw_status or "pending")
5700
+ if inferred_status and status in {"", "pending", "todo"}:
5701
+ status = inferred_status
5373
5702
  if status not in {"pending", "in_progress", "completed"}:
5374
- status = "pending"
5703
+ status = inferred_status or "pending"
5375
5704
  content = normalize_work_text(content, status) or content
5376
5705
  active_form = str(
5377
5706
  raw.get(
@@ -12526,10 +12855,10 @@ TOOLS = [
12526
12855
  ),
12527
12856
  tool_def("write_file", "Write file content.", {"path": {"type": "string"}, "content": {"type": "string"}}, ["path", "content"]),
12528
12857
  tool_def("edit_file", "Edit a file by replacing first match.", {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, ["path", "old_text", "new_text"]),
12529
- tool_def("TodoWrite", "Update todo list. Items can be strings or objects with content/status/owner fields.", {"items": {"type": "array", "items": {}}}, ["items"]),
12858
+ tool_def("TodoWrite", "Update todo list. Preferred format: objects with content/status/owner/parent_step_id. String fallback should use only '[ ] task', '[>] task', or '[x] task'.", {"items": {"type": "array", "items": {}}}, ["items"]),
12530
12859
  tool_def(
12531
12860
  "TodoWriteRescue",
12532
- "Fallback todo writer. Accepts strings with status prefixes: '[x] task' or ' task' = completed, '[>] task' = in_progress, plain text = pending. Also accepts dicts with status field.",
12861
+ "Fallback todo writer. Preferred format: objects with content/status/owner/parent_step_id. String fallback should use only '[ ] task', '[>] task', or '[x] task'.",
12533
12862
  {
12534
12863
  "items": {"type": "array", "items": {}},
12535
12864
  "in_progress_index": {"type": "integer"},
@@ -12963,6 +13292,8 @@ class SessionState:
12963
13292
  self._cached_llm_complexity = ""
12964
13293
  self._cached_complexity_dimensions: dict = {} # scope/steps/skill/output dimensions
12965
13294
  self._pending_media_inputs: list[dict] = []
13295
+ self._pending_runtime_updates: list[dict] = []
13296
+ self._deferred_runtime_sync_requested = False
12966
13297
  self.tool_retry_counts: dict[str, int] = {}
12967
13298
  self.last_auto_title_ts = 0.0
12968
13299
  self.live_thinking_text = ""
@@ -14459,9 +14790,9 @@ class SessionState:
14459
14790
  )
14460
14791
  if task_type in TASK_PROFILE_TYPES:
14461
14792
  self.runtime_task_type = task_type
14462
- complexity = trim(
14463
- str(profile.get("complexity", judgement.get("complexity", self.runtime_task_complexity or "")) or "").strip().lower(),
14464
- 20,
14793
+ complexity = normalize_task_complexity(
14794
+ profile.get("complexity", judgement.get("complexity", self.runtime_task_complexity or "")),
14795
+ default="simple",
14465
14796
  )
14466
14797
  if complexity in TASK_COMPLEXITY_LEVELS:
14467
14798
  self.runtime_task_complexity = complexity
@@ -14931,12 +15262,15 @@ class SessionState:
14931
15262
 
14932
15263
  def _current_plan_step_text(self, board: dict | None = None) -> str:
14933
15264
  row = self._current_plan_step_row(board)
14934
- return trim(str((row or {}).get("content", "") or "").strip(), 400)
15265
+ content = normalize_embedded_newlines((row or {}).get("content", "") or "").strip()
15266
+ if "\n" in content:
15267
+ content = content.split("\n", 1)[0].strip()
15268
+ return trim(content, 400)
14935
15269
 
14936
15270
  def _current_plan_step_full_text(self, board: dict | None = None, max_len: int = 1200) -> str:
14937
15271
  row = self._current_plan_step_row(board)
14938
15272
  return trim(
14939
- str((row or {}).get("full_content", "") or (row or {}).get("content", "") or "").strip(),
15273
+ normalize_embedded_newlines((row or {}).get("full_content", "") or (row or {}).get("content", "") or "").strip(),
14940
15274
  max_len,
14941
15275
  )
14942
15276
 
@@ -19985,11 +20319,17 @@ body{padding:18px}
19985
20319
  with self.lock:
19986
20320
  if self.running:
19987
20321
  config_delayed = True
19988
- self._pending_media_inputs.append({
19989
- "type": "deferred_config",
19990
- "config": cfg_obj,
19991
- "source": workspace_rel,
19992
- })
20322
+ if config_delayed:
20323
+ self._queue_deferred_runtime_update(
20324
+ "llm_config",
20325
+ {"config": cfg_obj, "source": workspace_rel},
20326
+ )
20327
+ loaded_config = self.model_catalog()
20328
+ if isinstance(loaded_config, dict):
20329
+ loaded_config["queued"] = True
20330
+ loaded_config["note"] = (
20331
+ "session is running; llm config queued and will apply after the current run finishes"
20332
+ )
19993
20333
  if not config_delayed:
19994
20334
  loaded_config = self.load_llm_config(cfg_obj, source=workspace_rel)
19995
20335
  self._emit("config_applied", {
@@ -21155,7 +21495,7 @@ body{padding:18px}
21155
21495
  return any(x in t for x in markers)
21156
21496
 
21157
21497
  def _llm_classify_task_complexity(self, goal_text: str) -> str:
21158
- """LLM semantic pre-screening: classify task as simple/complex via 4-dimension analysis. 5s timeout."""
21498
+ """LLM semantic pre-screening: classify task into 4 complexity bands via 4-dimension analysis. 5s timeout."""
21159
21499
  goal = trim(str(goal_text or ""), 400)
21160
21500
  if not goal or len(goal) < 6:
21161
21501
  return "simple"
@@ -21172,8 +21512,7 @@ body{padding:18px}
21172
21512
  f"SKILL: does it need specialized tools, skills, research, or APIs?\n"
21173
21513
  f"OUTPUT: what is expected (1=text answer, 2=single file, 3=system/multi-file)?\n\n"
21174
21514
  f"Output exactly one line:\n"
21175
- f"SCOPE:N STEPS:N SKILL:N OUTPUT:N VERDICT:SIMPLE|COMPLEX\n"
21176
- f"(COMPLEX if any dimension >= 2)"
21515
+ f"SCOPE:N STEPS:N SKILL:N OUTPUT:N VERDICT:SIMPLE|MODERATE|COMPLEX|EXPERT"
21177
21516
  )}],
21178
21517
  system="/no_think\nAnalyze task dimensions. One line output only.",
21179
21518
  max_tokens=40,
@@ -21188,8 +21527,26 @@ body{padding:18px}
21188
21527
  dims[dim.lower()] = int(m.group(1))
21189
21528
  if dims:
21190
21529
  self._cached_complexity_dimensions = dims
21191
- if "COMPLEX" in answer:
21530
+ vals = [int(v) for v in dims.values()]
21531
+ max_dim = max(vals) if vals else 1
21532
+ count_ge2 = sum(1 for v in vals if int(v) >= 2)
21533
+ count_ge3 = sum(1 for v in vals if int(v) >= 3)
21534
+ if max_dim <= 1:
21535
+ result_box[0] = "simple"
21536
+ elif max_dim == 2:
21537
+ result_box[0] = "moderate"
21538
+ elif count_ge3 >= 2 or count_ge2 >= 4:
21539
+ result_box[0] = "expert"
21540
+ else:
21541
+ result_box[0] = "complex"
21542
+ if "VERDICT:EXPERT" in answer:
21543
+ result_box[0] = "expert"
21544
+ elif "VERDICT:COMPLEX" in answer:
21192
21545
  result_box[0] = "complex"
21546
+ elif "VERDICT:MODERATE" in answer:
21547
+ result_box[0] = "moderate"
21548
+ elif "VERDICT:SIMPLE" in answer:
21549
+ result_box[0] = "simple"
21193
21550
  except Exception:
21194
21551
  pass
21195
21552
  t = threading.Thread(target=_classify, daemon=True)
@@ -21202,9 +21559,9 @@ body{padding:18px}
21202
21559
  low = clean.lower()
21203
21560
  explicit_complexity = infer_user_complexity_value(clean)
21204
21561
  # Use cached LLM complexity result (set by _agent_worker entry point)
21205
- llm_complexity = str(getattr(self, '_cached_llm_complexity', '') or '')
21206
- nontrivial = self._looks_nontrivial_request(clean) or llm_complexity == "complex"
21207
- direct_question = self._looks_like_direct_question_request(clean) and llm_complexity != "complex"
21562
+ llm_complexity = normalize_task_complexity(str(getattr(self, '_cached_llm_complexity', '') or ''), default="simple")
21563
+ nontrivial = self._looks_nontrivial_request(clean) or task_complexity_at_least(llm_complexity, "moderate")
21564
+ direct_question = self._looks_like_direct_question_request(clean) and (not task_complexity_at_least(llm_complexity, "moderate"))
21208
21565
  code_markers = [
21209
21566
  # 代码/编程
21210
21567
  "代码", "寫代碼", "写代码", "脚本", "模块", "函数", "class", "bug",
@@ -21241,6 +21598,7 @@ body{padding:18px}
21241
21598
  has_code_intent = any(x in low for x in code_markers)
21242
21599
  has_research_intent = any(x in low for x in research_markers)
21243
21600
  length = len(clean)
21601
+ derived_complexity = max_task_complexity(explicit_complexity, llm_complexity, default="simple")
21244
21602
  if direct_question and (not nontrivial) and (not has_code_intent) and length <= 220:
21245
21603
  return {
21246
21604
  "task_type": "simple_qa",
@@ -21271,7 +21629,11 @@ body{padding:18px}
21271
21629
  if has_research_intent and (not has_code_intent):
21272
21630
  return {
21273
21631
  "task_type": "research",
21274
- "complexity": explicit_complexity or ("complex" if (nontrivial or length >= 280) else "simple"),
21632
+ "complexity": explicit_complexity or max_task_complexity(
21633
+ derived_complexity,
21634
+ ("complex" if length >= 480 else "moderate" if (nontrivial or length >= 280) else "simple"),
21635
+ default="simple",
21636
+ ),
21275
21637
  "direct_objective": "Collect evidence first, then synthesize a concise actionable answer.",
21276
21638
  "recommended_agents": ["explorer", "developer", "reviewer"],
21277
21639
  "round_budget": 10 if (nontrivial or length >= 280) else 6,
@@ -21282,7 +21644,15 @@ body{padding:18px}
21282
21644
  if nontrivial or has_code_intent or length >= 280:
21283
21645
  return {
21284
21646
  "task_type": "engineering",
21285
- "complexity": explicit_complexity or "complex",
21647
+ "complexity": explicit_complexity or max_task_complexity(
21648
+ derived_complexity,
21649
+ (
21650
+ "expert"
21651
+ if ((has_code_intent and has_research_intent) or length >= 900)
21652
+ else "complex"
21653
+ ),
21654
+ default="moderate",
21655
+ ),
21286
21656
  "direct_objective": (
21287
21657
  "Use blackboard collaboration to implement, validate, and converge with concrete outputs."
21288
21658
  ),
@@ -21294,7 +21664,7 @@ body{padding:18px}
21294
21664
  }
21295
21665
  return {
21296
21666
  "task_type": "general",
21297
- "complexity": explicit_complexity or "simple",
21667
+ "complexity": explicit_complexity or derived_complexity or "simple",
21298
21668
  "direct_objective": (
21299
21669
  "Provide the most direct useful response with minimal orchestration, "
21300
21670
  "anchored to the current project context and user goal."
@@ -21556,6 +21926,66 @@ body{padding:18px}
21556
21926
  model = str(profile.get("model", self.ollama.model) or self.ollama.model).strip()
21557
21927
  return f"{self.active_profile_id}::{model}"
21558
21928
 
21929
+ def _queue_deferred_runtime_update(self, kind: str, payload: dict) -> int:
21930
+ row = {
21931
+ "kind": str(kind or "").strip().lower(),
21932
+ "payload": dict(payload or {}),
21933
+ "queued_at": float(now_ts()),
21934
+ }
21935
+ if not row["kind"]:
21936
+ raise ValueError("deferred runtime update kind required")
21937
+ with self.lock:
21938
+ self._pending_runtime_updates.append(row)
21939
+ self._pending_runtime_updates = self._pending_runtime_updates[-16:]
21940
+ queued = len(self._pending_runtime_updates)
21941
+ self.updated_at = now_ts()
21942
+ self._persist()
21943
+ return queued
21944
+
21945
+ def _apply_deferred_runtime_updates(self) -> list[str]:
21946
+ with self.lock:
21947
+ if self.running or not self._pending_runtime_updates:
21948
+ return []
21949
+ queued = list(self._pending_runtime_updates)
21950
+ self._pending_runtime_updates = []
21951
+ self.updated_at = now_ts()
21952
+ self._persist()
21953
+ applied_notes: list[str] = []
21954
+ sync_needed = False
21955
+ for item in queued:
21956
+ kind = str(item.get("kind", "") or "").strip().lower()
21957
+ payload = item.get("payload", {}) if isinstance(item.get("payload"), dict) else {}
21958
+ try:
21959
+ if kind == "llm_config":
21960
+ source = str(payload.get("source", "") or "deferred-config").strip()
21961
+ config = payload.get("config", {})
21962
+ if isinstance(config, dict) and config:
21963
+ self.load_llm_config(config, source=source)
21964
+ applied_notes.append(f"deferred llm config applied: {trim(source, 120)}")
21965
+ sync_needed = True
21966
+ elif kind == "model_selection":
21967
+ selection = str(payload.get("selection", "") or "").strip()
21968
+ model_override = payload.get("model_override")
21969
+ self.set_runtime_selection(
21970
+ selection,
21971
+ model_override if isinstance(model_override, str) else None,
21972
+ )
21973
+ applied_notes.append(f"deferred model switch applied: {trim(selection, 120)}")
21974
+ sync_needed = True
21975
+ except Exception as exc:
21976
+ self._emit(
21977
+ "status",
21978
+ {
21979
+ "summary": (
21980
+ f"deferred runtime update failed ({kind or 'unknown'}): "
21981
+ f"{trim(str(exc), 180)}"
21982
+ )
21983
+ },
21984
+ )
21985
+ if sync_needed:
21986
+ self._deferred_runtime_sync_requested = True
21987
+ return applied_notes
21988
+
21559
21989
  def _global_wait_timeout_seconds(self) -> int:
21560
21990
  raw = (
21561
21991
  self.max_run_seconds
@@ -22107,6 +22537,7 @@ body{padding:18px}
22107
22537
  _spawn_reader("stdout", proc.stdout)
22108
22538
  _spawn_reader("stderr", proc.stderr)
22109
22539
 
22540
+ _auto_confirmed: set[bytes] = set()
22110
22541
  while True:
22111
22542
  now = time.time()
22112
22543
  elapsed = now - start
@@ -22114,10 +22545,12 @@ body{padding:18px}
22114
22545
  _stop_process(proc)
22115
22546
  meta["error"] = "Error: interrupted by user"
22116
22547
  meta["exit_code"] = -130
22548
+ break
22117
22549
  elif (not meta.get("error")) and timeout > 0 and elapsed >= timeout:
22118
22550
  _stop_process(proc)
22119
22551
  meta["error"] = f"Error: timeout ({timeout}s)"
22120
22552
  meta["exit_code"] = -1
22553
+ break
22121
22554
  try:
22122
22555
  label, chunk = io_queue.get(timeout=0.12)
22123
22556
  if chunk is None:
@@ -22150,6 +22583,18 @@ body{padding:18px}
22150
22583
  },
22151
22584
  )
22152
22585
  next_progress_emit = now + 0.8
22586
+ # Auto-confirm interactive prompts (Windows reader thread path)
22587
+ if proc.stdin and not proc.stdin.closed:
22588
+ _tail = (bytes(out_buf[-300:]) + bytes(err_buf[-300:])).lower()
22589
+ for _pat in _SHELL_AUTO_CONFIRM_PATTERNS:
22590
+ if _pat in _tail and _pat not in _auto_confirmed:
22591
+ try:
22592
+ proc.stdin.write(b"y\n")
22593
+ proc.stdin.flush()
22594
+ except Exception:
22595
+ pass
22596
+ _auto_confirmed.add(_pat)
22597
+ break
22153
22598
  if (proc.poll() is not None) and (not active_readers) and io_queue.empty():
22154
22599
  break
22155
22600
 
@@ -22190,6 +22635,7 @@ body{padding:18px}
22190
22635
  popen_kwargs = {
22191
22636
  "shell": True,
22192
22637
  "cwd": cwd,
22638
+ "stdin": subprocess.PIPE,
22193
22639
  "stdout": subprocess.PIPE,
22194
22640
  "stderr": subprocess.PIPE,
22195
22641
  "text": False,
@@ -22202,6 +22648,7 @@ body{padding:18px}
22202
22648
  if create_group > 0:
22203
22649
  popen_kwargs["creationflags"] = create_group
22204
22650
  proc = subprocess.Popen(effective_command, **popen_kwargs)
22651
+ self._running_bash_proc = proc
22205
22652
  if os.name == "nt":
22206
22653
  # Windows: read PIPE output via blocking reader threads + queue.
22207
22654
  _collect_with_reader_threads(proc)
@@ -22220,6 +22667,7 @@ body{padding:18px}
22220
22667
  except Exception:
22221
22668
  pass
22222
22669
  sel.register(proc.stderr, selectors.EVENT_READ, data="stderr")
22670
+ _auto_confirmed: set[bytes] = set()
22223
22671
  while True:
22224
22672
  now = time.time()
22225
22673
  elapsed = now - start
@@ -22227,10 +22675,12 @@ body{padding:18px}
22227
22675
  _stop_process(proc)
22228
22676
  meta["error"] = "Error: interrupted by user"
22229
22677
  meta["exit_code"] = -130
22678
+ break
22230
22679
  elif timeout > 0 and elapsed >= timeout:
22231
22680
  _stop_process(proc)
22232
22681
  meta["error"] = f"Error: timeout ({timeout}s)"
22233
22682
  meta["exit_code"] = -1
22683
+ break
22234
22684
  events = sel.select(timeout=0.12)
22235
22685
  for key, _ in events:
22236
22686
  stream = key.fileobj
@@ -22261,6 +22711,18 @@ body{padding:18px}
22261
22711
  },
22262
22712
  )
22263
22713
  next_progress_emit = now + 0.8
22714
+ # Auto-confirm interactive prompts (e.g. "Ok to proceed? (y)")
22715
+ if proc.stdin and not proc.stdin.closed:
22716
+ _tail = (bytes(out_buf[-300:]) + bytes(err_buf[-300:])).lower()
22717
+ for _pat in _SHELL_AUTO_CONFIRM_PATTERNS:
22718
+ if _pat in _tail and _pat not in _auto_confirmed:
22719
+ try:
22720
+ proc.stdin.write(b"y\n")
22721
+ proc.stdin.flush()
22722
+ except Exception:
22723
+ pass
22724
+ _auto_confirmed.add(_pat)
22725
+ break
22264
22726
  if (proc.poll() is not None) and (not sel.get_map()):
22265
22727
  break
22266
22728
  merged_raw = _merge_output_text()
@@ -22288,6 +22750,8 @@ body{padding:18px}
22288
22750
  meta["error"] = f"Error: {exc}"
22289
22751
  meta["output"] = meta["error"]
22290
22752
  meta["exit_code"] = -1
22753
+ finally:
22754
+ self._running_bash_proc = None
22291
22755
  meta["duration_ms"] = int((time.time() - start) * 1000)
22292
22756
  after = self._git_status_map(cwd)
22293
22757
  meta["changed_files"] = self._status_delta(before, after) if before or after else []
@@ -23175,9 +23639,10 @@ body{padding:18px}
23175
23639
  ) or str(base.get("task_type", "general"))
23176
23640
  if task_type not in TASK_PROFILE_TYPES:
23177
23641
  task_type = str(base.get("task_type", "general"))
23178
- complexity = str(src.get("complexity", base.get("complexity", "simple")) or "").strip().lower()
23179
- if complexity not in TASK_COMPLEXITY_LEVELS:
23180
- complexity = str(base.get("complexity", "simple"))
23642
+ complexity = normalize_task_complexity(
23643
+ src.get("complexity", base.get("complexity", "simple")),
23644
+ default=str(base.get("complexity", "simple") or "simple"),
23645
+ )
23181
23646
  src_direct_objective = trim(str(src.get("direct_objective", "") or "").strip(), 800)
23182
23647
  legacy_objectives = {
23183
23648
  "Provide the most direct useful response with minimal orchestration.",
@@ -23214,9 +23679,9 @@ body{padding:18px}
23214
23679
  if raw_level not in TASK_LEVEL_CHOICES:
23215
23680
  if task_type == "simple_qa":
23216
23681
  raw_level = 1 if len(str(goal or "")) <= 180 else 2
23217
- elif task_type in {"simple_code", "research"} and complexity == "simple":
23682
+ elif task_type in {"simple_code", "research"} and task_complexity_rank(complexity) <= task_complexity_rank("moderate"):
23218
23683
  raw_level = 3
23219
- elif complexity == "complex":
23684
+ elif task_complexity_at_least(complexity, "complex"):
23220
23685
  raw_level = 4
23221
23686
  else:
23222
23687
  raw_level = 2
@@ -23305,7 +23770,7 @@ body{padding:18px}
23305
23770
  goal = str(bb.get("original_goal", "") or "")
23306
23771
  current = bb.get("task_profile", {})
23307
23772
  profile = self._normalize_task_profile(goal, {} if force else current)
23308
- if profile.get("complexity") == "simple":
23773
+ if task_complexity_rank(profile.get("complexity", "simple")) < task_complexity_rank("complex"):
23309
23774
  logs = bb.get("execution_logs", []) if isinstance(bb.get("execution_logs"), list) else []
23310
23775
  tail = "\n".join(
23311
23776
  str((row or {}).get("content", "") or "")
@@ -23409,10 +23874,16 @@ body{padding:18px}
23409
23874
  # Project todo gate: coding tasks must pass compile + test
23410
23875
  profile = self._ensure_blackboard_task_profile(bb)
23411
23876
  task_type = str(profile.get("task_type", "general") or "general")
23877
+ exec_mode = normalize_execution_mode(
23878
+ profile.get("execution_mode", self._effective_execution_mode()),
23879
+ default=self._effective_execution_mode(),
23880
+ )
23412
23881
  if task_type in ("simple_code", "engineering"):
23413
23882
  for todo in bb.get("project_todos", []):
23414
23883
  if todo.get("category") in ("compile_test", "min_test") and todo.get("status") != "completed":
23415
23884
  return False, f"project-todo-incomplete:{todo.get('category', '')}"
23885
+ if exec_mode == EXECUTION_MODE_SYNC and not self._manager_feedback_passed_from_blackboard(bb):
23886
+ return False, "sync-review-missing"
23416
23887
  return True, "ok"
23417
23888
 
23418
23889
  def _invalidate_stale_approval_if_needed(
@@ -23630,6 +24101,10 @@ body{padding:18px}
23630
24101
  def _watchdog_state_fingerprint(self, board: dict | None = None) -> str:
23631
24102
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
23632
24103
  profile = self._ensure_blackboard_task_profile(bb)
24104
+ step_snapshot = self._active_plan_progress_snapshot(bb)
24105
+ last_reply = bb.get("last_worker_reply", {}) if isinstance(bb.get("last_worker_reply"), dict) else {}
24106
+ last_reply_role = self._sanitize_agent_role(last_reply.get("role", ""))
24107
+ last_reply_text = trim(str(last_reply.get("text", "") or "").strip(), 240)
23633
24108
  payload = {
23634
24109
  "status": self._normalize_blackboard_status(bb.get("status", "INITIALIZING")),
23635
24110
  "goal": trim(str(bb.get("original_goal", "") or "").strip(), 400),
@@ -23642,6 +24117,16 @@ body{padding:18px}
23642
24117
  "approved": bool((bb.get("approval", {}) or {}).get("approved", False)),
23643
24118
  "task_type": str(profile.get("task_type", "general") or "general"),
23644
24119
  "complexity": str(profile.get("complexity", "simple") or "simple"),
24120
+ "plan_step_id": str(step_snapshot.get("step_id", "") or ""),
24121
+ "plan_step_text": trim(str(step_snapshot.get("step_text", "") or "").strip(), 180),
24122
+ "worker_todo_count": int(step_snapshot.get("worker_todo_count", 0) or 0),
24123
+ "worker_todo_completed": int(step_snapshot.get("completed_count", 0) or 0),
24124
+ "worker_todo_in_progress": int(step_snapshot.get("in_progress_count", 0) or 0),
24125
+ "worker_todo_pending": int(step_snapshot.get("pending_count", 0) or 0),
24126
+ "current_subtask": trim(str(step_snapshot.get("current_subtask", "") or "").strip(), 180),
24127
+ "next_pending_subtask": trim(str(step_snapshot.get("next_pending_subtask", "") or "").strip(), 180),
24128
+ "last_worker_reply_role": last_reply_role,
24129
+ "last_worker_reply_text": last_reply_text,
23645
24130
  }
23646
24131
  raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
23647
24132
  return hashlib.sha1(raw.encode("utf-8")).hexdigest()
@@ -24286,6 +24771,7 @@ body{padding:18px}
24286
24771
  "instruction": "",
24287
24772
  "reason": "",
24288
24773
  "source": "",
24774
+ "progress_fp": "",
24289
24775
  "is_mandatory": False,
24290
24776
  "ts": 0.0,
24291
24777
  },
@@ -24341,6 +24827,7 @@ body{padding:18px}
24341
24827
  "instruction": trim(str(raw_delegate.get("instruction", "") or "").strip(), 1200),
24342
24828
  "reason": trim(str(raw_delegate.get("reason", "") or "").strip(), 600),
24343
24829
  "source": trim(str(raw_delegate.get("source", "") or "").strip(), 40),
24830
+ "progress_fp": trim(str(raw_delegate.get("progress_fp", "") or "").strip(), 80),
24344
24831
  "is_mandatory": _to_bool_like(raw_delegate.get("is_mandatory", False), default=False),
24345
24832
  "ts": float(raw_delegate.get("ts", 0.0) or 0.0),
24346
24833
  }
@@ -24478,8 +24965,8 @@ body{padding:18px}
24478
24965
  for pt in bb_src_todos[:40]:
24479
24966
  if not isinstance(pt, dict):
24480
24967
  continue
24481
- raw_content = trim(str(pt.get("content", "") or ""), PLAN_STEP_FULL_CONTENT_MAX_CHARS)
24482
- raw_full = trim(str(pt.get("full_content", "") or ""), PLAN_STEP_FULL_CONTENT_MAX_CHARS)
24968
+ raw_content = trim(normalize_embedded_newlines(pt.get("content", "")), PLAN_STEP_FULL_CONTENT_MAX_CHARS)
24969
+ raw_full = trim(normalize_embedded_newlines(pt.get("full_content", "")), PLAN_STEP_FULL_CONTENT_MAX_CHARS)
24483
24970
  # Migration: if full_content is empty but content has sub-steps, auto-split
24484
24971
  if not raw_full and raw_content and pt.get("category") == "plan_step":
24485
24972
  normalized = _mid_re_norm.sub(r"\n\1", raw_content)
@@ -24892,7 +25379,8 @@ body{padding:18px}
24892
25379
  if not isinstance(fl, dict):
24893
25380
  return
24894
25381
  delegations = fl.get("repeated_delegations", [])
24895
- fp = hashlib.sha1(str(instruction or "").encode("utf-8")).hexdigest()[:12]
25382
+ progress_fp = self._watchdog_state_fingerprint(bb)
25383
+ fp = hashlib.sha1((str(instruction or "") + "|" + progress_fp).encode("utf-8")).hexdigest()[:12]
24896
25384
  for entry in delegations:
24897
25385
  if entry.get("instruction_hash") == fp and entry.get("target") == target:
24898
25386
  entry["count"] = int(entry.get("count", 1) or 1) + 1
@@ -24905,6 +25393,7 @@ body{padding:18px}
24905
25393
  "target": trim(str(target or ""), 40),
24906
25394
  "instruction_hash": fp,
24907
25395
  "instruction_preview": trim(str(instruction or ""), 200),
25396
+ "progress_fp": progress_fp,
24908
25397
  "count": 1,
24909
25398
  "first_round": int(getattr(self, "agent_round_index", 0) or 0),
24910
25399
  "last_round": int(getattr(self, "agent_round_index", 0) or 0),
@@ -24970,6 +25459,12 @@ body{padding:18px}
24970
25459
  board["updated_at"] = float(now_ts())
24971
25460
  self.blackboard = board
24972
25461
 
25462
+ def _save_blackboard(self, bb: dict):
25463
+ """Persist a blackboard dict as the current blackboard and touch updated_at."""
25464
+ if isinstance(bb, dict):
25465
+ bb["updated_at"] = float(now_ts())
25466
+ self.blackboard = bb
25467
+
24973
25468
  def _blackboard_reset_for_goal(self, goal: str):
24974
25469
  # Preserve plan state when safe, but refresh loaded skills on goal change.
24975
25470
  old_bb = self._ensure_blackboard()
@@ -25857,6 +26352,16 @@ body{padding:18px}
25857
26352
  current["completed_at"] = float(now_ts())
25858
26353
  current["completed_by"] = actor
25859
26354
  current["evidence"] = trim(str(evidence or "").strip(), 200) or self._ui_text("step_completed_evidence")
26355
+ # Clear single-mode validation gate flags for the completed step
26356
+ try:
26357
+ _completed_id = str(current.get("id", "") or "")
26358
+ for _attr_name in (f"_smvg_{_completed_id}", f"_smvg_ts_{_completed_id}", f"_smvg_n_{_completed_id}", f"_sync_exec_gate_n_{_completed_id}", f"_sync_sv_ts_{_completed_id}"):
26359
+ try:
26360
+ delattr(self, _attr_name)
26361
+ except AttributeError:
26362
+ pass
26363
+ except Exception:
26364
+ pass
25860
26365
  # 推进 cursor,激活下一步
25861
26366
  cursor = int(bb.get("plan_step_cursor", 0) or 0)
25862
26367
  bb["plan_step_cursor"] = cursor + 1
@@ -26024,7 +26529,33 @@ body{padding:18px}
26024
26529
  )
26025
26530
  ) or accumulated_evidence_path
26026
26531
  if has_strong_evidence:
26532
+ # Sync mode exec gate: when all subtasks done for implement/test/deploy phases,
26533
+ # require at least some execution evidence (bash/test/compile ran at any point).
26534
+ # Manager-requested advancement has its own escape hatch after 10 blocks.
26535
+ _exec_gate_needed = (
26536
+ subtasks_all_done
26537
+ and phase in ("implement", "test", "deploy")
26538
+ )
26539
+ if _exec_gate_needed:
26540
+ # Require model's explicit <step-verified/> tag in agent_messages since step activation
26541
+ _has_verified = self._check_step_verified_tag(current, messages=self.agent_messages)
26542
+ if not _has_verified:
26543
+ _sync_n_flag = f"_sync_exec_gate_n_{str(current.get('id', '') or '')}"
26544
+ _sync_n = int(getattr(self, _sync_n_flag, 0))
26545
+ if _sync_n < 10:
26546
+ setattr(self, _sync_n_flag, _sync_n + 1)
26547
+ # No verified tag yet — push worker to evaluate and emit <step-verified/>
26548
+ self._inject_sync_mode_verification_hint(current, worker_step)
26549
+ return
26550
+ # After 10 blocks, allow advancement to prevent permanent stall
26027
26551
  evidence = self._collect_step_evidence(current, worker_step)
26552
+ # Clear sync exec gate counter on successful advance
26553
+ try:
26554
+ _sync_clear = f"_sync_exec_gate_n_{str(current.get('id', '') or '')}"
26555
+ if hasattr(self, _sync_clear):
26556
+ delattr(self, _sync_clear)
26557
+ except Exception:
26558
+ pass
26028
26559
  self._advance_plan_step(
26029
26560
  evidence=evidence,
26030
26561
  actor=str(route.get("target", "developer") or "developer"),
@@ -26139,13 +26670,6 @@ body{padding:18px}
26139
26670
  all_marked_done = all(str(r.get("status", "")).lower() == "completed" for r in worker_items)
26140
26671
  if not all_marked_done:
26141
26672
  return False
26142
- # Acceptance verification: check that each "completed" subtask has real evidence
26143
- # Don't just trust the model's TodoWrite status — verify against accumulated tool outputs
26144
- if worker_items:
26145
- bb = self._ensure_blackboard()
26146
- unverified = self._verify_subtasks_acceptance(worker_items, step_id, bb)
26147
- if unverified:
26148
- return False
26149
26673
  return True
26150
26674
 
26151
26675
  def _verify_subtasks_acceptance(self, subtasks: list[dict], step_id: str, bb: dict) -> list[str]:
@@ -26392,6 +26916,10 @@ body{padding:18px}
26392
26916
  parts.append(f"bash: {cmd}" + (f" => {out}" if out else ""))
26393
26917
  elif name == "read_file":
26394
26918
  path = str(r.get("args", {}).get("path", "") or "")
26919
+ # Skip plan-infrastructure reads — not meaningful implementation evidence
26920
+ _p = str(path)
26921
+ if (_p.endswith("plan.md") and ".clouds_coder" in _p) or ".clouds_coder/skills_cache/" in _p:
26922
+ continue
26395
26923
  out = self._tool_result_output_excerpt(r, 90)
26396
26924
  parts.append(f"read: {path}" + (f" => {out}" if out else ""))
26397
26925
  elif name in ("write_to_blackboard", "query_code_library", "query_knowledge_library"):
@@ -26463,10 +26991,342 @@ body{padding:18px}
26463
26991
  return False
26464
26992
  return bool(self._active_plan_worker_todo_rows(step_id, role=role))
26465
26993
 
26994
+ def _bridge_flat_todos_to_active_plan_step(
26995
+ self,
26996
+ rows: list[dict] | None,
26997
+ board: dict | None = None,
26998
+ ) -> tuple[list[dict], bool]:
26999
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
27000
+ step = self._get_active_plan_step(bb)
27001
+ if not isinstance(step, dict):
27002
+ return (list(rows or []), False)
27003
+ step_id = trim(str(step.get("id", "") or ""), 20)
27004
+ if not step_id:
27005
+ return (list(rows or []), False)
27006
+ snap = [dict(row) for row in (rows or []) if isinstance(row, dict)]
27007
+ if not snap:
27008
+ return (snap, False)
27009
+ worker_owners = {"developer", "explorer", "reviewer"}
27010
+ if any(str(row.get("parent_step_id", "") or "").strip() for row in snap):
27011
+ return (snap, False)
27012
+ if any(
27013
+ str(row.get("owner", "") or "").strip().lower() in worker_owners
27014
+ and str(row.get("parent_step_id", "") or "").strip() == step_id
27015
+ for row in snap
27016
+ ):
27017
+ return (snap, False)
27018
+ owner_key = self._current_plan_worker_owner(bb)
27019
+ bridged: list[dict] = []
27020
+ migrated = False
27021
+ for row in snap:
27022
+ key = trim(str(row.get("key", "") or "").strip(), 120)
27023
+ if key.startswith("bb:"):
27024
+ bridged.append(dict(row))
27025
+ continue
27026
+ content = normalize_work_text(str(row.get("content", "") or "")) or str(row.get("content", "") or "").strip()
27027
+ if not content:
27028
+ continue
27029
+ new_row = dict(row)
27030
+ new_row["content"] = content
27031
+ new_row["parent_step_id"] = step_id
27032
+ owner = str(new_row.get("owner", "") or "").strip().lower()
27033
+ if owner not in worker_owners:
27034
+ new_row["owner"] = owner_key
27035
+ bridged.append(new_row)
27036
+ migrated = True
27037
+ return (bridged, migrated)
27038
+
27039
+ def _active_plan_progress_snapshot(self, board: dict | None = None) -> dict:
27040
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
27041
+ step = self._current_plan_step_row(bb)
27042
+ if not isinstance(step, dict):
27043
+ return {
27044
+ "step_id": "",
27045
+ "step_index": 0,
27046
+ "step_text": "",
27047
+ "expected_count": 0,
27048
+ "worker_todo_count": 0,
27049
+ "completed_count": 0,
27050
+ "in_progress_count": 0,
27051
+ "pending_count": 0,
27052
+ "current_subtask": "",
27053
+ "next_pending_subtask": "",
27054
+ "owners": [],
27055
+ }
27056
+ step_id = trim(str(step.get("id", "") or ""), 20)
27057
+ rows = self._active_plan_worker_todo_rows(step_id, role="") if step_id else []
27058
+ expected = self._extract_plan_step_subtasks(step, limit=5)
27059
+ completed_count = 0
27060
+ in_progress_count = 0
27061
+ pending_count = 0
27062
+ current_subtask = ""
27063
+ next_pending_subtask = ""
27064
+ owners: set[str] = set()
27065
+ for row in rows:
27066
+ status = str(row.get("status", "pending") or "pending").strip().lower()
27067
+ content = trim(str(row.get("content", "") or "").strip(), 220)
27068
+ owner = self._sanitize_agent_role(row.get("owner", ""))
27069
+ if owner:
27070
+ owners.add(owner)
27071
+ if status == "completed":
27072
+ completed_count += 1
27073
+ elif status == "in_progress":
27074
+ in_progress_count += 1
27075
+ if content and not current_subtask:
27076
+ current_subtask = content
27077
+ else:
27078
+ pending_count += 1
27079
+ if content and not next_pending_subtask:
27080
+ next_pending_subtask = content
27081
+ return {
27082
+ "step_id": step_id,
27083
+ "step_index": max(0, int(step.get("plan_step_index", 0) or 0)),
27084
+ "step_text": self._current_plan_step_text(bb),
27085
+ "expected_count": len(expected),
27086
+ "worker_todo_count": len(rows),
27087
+ "completed_count": completed_count,
27088
+ "in_progress_count": in_progress_count,
27089
+ "pending_count": pending_count,
27090
+ "current_subtask": current_subtask,
27091
+ "next_pending_subtask": next_pending_subtask,
27092
+ "owners": sorted(owners),
27093
+ }
27094
+
27095
+ def _manager_worker_progress_capsule(self, role: str, step: dict, board: dict | None = None) -> str:
27096
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
27097
+ role_key = self._sanitize_agent_role(role) or "developer"
27098
+ safe_step = step if isinstance(step, dict) else {}
27099
+ snapshot = self._active_plan_progress_snapshot(bb)
27100
+ tool_results = safe_step.get("tool_results", []) or []
27101
+ tool_names: list[str] = []
27102
+ for item in tool_results:
27103
+ if not isinstance(item, dict):
27104
+ continue
27105
+ name = str(item.get("name", "") or "").strip()
27106
+ if not name or name in tool_names:
27107
+ continue
27108
+ tool_names.append(name)
27109
+ if len(tool_names) >= 5:
27110
+ break
27111
+ parts = [
27112
+ f"[worker-progress] owner={role_key}",
27113
+ f"status={trim(str(safe_step.get('status', '') or ''), 40) or '-'}",
27114
+ ]
27115
+ if tool_names:
27116
+ parts.append("tools=" + ",".join(tool_names))
27117
+ step_text = trim(str(snapshot.get("step_text", "") or ""), 180)
27118
+ if step_text:
27119
+ parts.append(f"step={step_text}")
27120
+ todo_state = (
27121
+ f"todos={int(snapshot.get('completed_count', 0) or 0)}/"
27122
+ f"{int(snapshot.get('in_progress_count', 0) or 0)}/"
27123
+ f"{int(snapshot.get('pending_count', 0) or 0)}"
27124
+ )
27125
+ if int(snapshot.get("worker_todo_count", 0) or 0) > 0:
27126
+ parts.append(todo_state)
27127
+ elif int(snapshot.get("expected_count", 0) or 0) > 0:
27128
+ parts.append(f"todos=missing/{int(snapshot.get('expected_count', 0) or 0)}")
27129
+ focus = trim(str(snapshot.get("current_subtask", "") or ""), 160)
27130
+ if focus:
27131
+ parts.append(f"focus={focus}")
27132
+ elif str(snapshot.get("next_pending_subtask", "") or "").strip():
27133
+ parts.append(f"next={trim(str(snapshot.get('next_pending_subtask', '') or ''), 160)}")
27134
+ current_step = self._current_plan_step_row(bb)
27135
+ if isinstance(current_step, dict):
27136
+ evidence = self._collect_blackboard_step_evidence(current_step, bb)
27137
+ if evidence:
27138
+ parts.append(f"evidence={trim(evidence, 180)}")
27139
+ if self._step_subtasks_all_completed(current_step) and self._plan_step_has_blackboard_evidence(current_step, bb):
27140
+ parts.append("acceptance=ready")
27141
+ reply = bb.get("last_worker_reply", {}) if isinstance(bb.get("last_worker_reply"), dict) else {}
27142
+ if self._sanitize_agent_role(reply.get("role", "")) == role_key:
27143
+ reply_text = trim(str(reply.get("text", "") or "").strip(), 180)
27144
+ if reply_text:
27145
+ parts.append(f"reply={reply_text}")
27146
+ return trim(" | ".join(parts), 1600)
27147
+
27148
+ def _manager_recovery_route_for_repeated_delegate(self, route: dict, board: dict | None = None) -> dict:
27149
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
27150
+ row = dict(route or {})
27151
+ step = self._current_plan_step_row(bb)
27152
+ if not isinstance(step, dict):
27153
+ row["target"] = "developer"
27154
+ row["instruction"] = (
27155
+ "Recovery routing after repeated identical delegation. "
27156
+ "Continue the current objective with one concrete tool action and write observable progress."
27157
+ )
27158
+ row["reason"] = trim(f"{row.get('reason', '')}|loop-recovery-no-plan-step", 600)
27159
+ row["source"] = "loop-recovery"
27160
+ row["is_mandatory"] = True
27161
+ return row
27162
+ snapshot = self._active_plan_progress_snapshot(bb)
27163
+ step_text = trim(str(snapshot.get("step_text", "") or ""), 220)
27164
+ full_text = self._current_plan_step_full_text(bb, max_len=600)
27165
+ phase = self._plan_step_phase_hint(full_text)
27166
+ expected_count = int(snapshot.get("expected_count", 0) or 0)
27167
+ worker_todo_count = int(snapshot.get("worker_todo_count", 0) or 0)
27168
+ current_subtask = trim(str(snapshot.get("current_subtask", "") or ""), 180)
27169
+ next_pending = trim(str(snapshot.get("next_pending_subtask", "") or ""), 180)
27170
+ subtasks_done = self._step_subtasks_all_completed(step)
27171
+ has_evidence = self._plan_step_has_blackboard_evidence(step, bb)
27172
+ reviewer_available = True
27173
+ profile = self._ensure_blackboard_task_profile(bb)
27174
+ participants = profile.get("participants", []) if isinstance(profile.get("participants"), list) else []
27175
+ participants_norm = [self._sanitize_agent_role(x) for x in participants]
27176
+ participants_norm = [x for x in participants_norm if x]
27177
+ if participants_norm:
27178
+ reviewer_available = "reviewer" in participants_norm
27179
+ if subtasks_done and has_evidence and reviewer_available:
27180
+ row["target"] = "reviewer"
27181
+ row["instruction"] = trim(
27182
+ (
27183
+ "Recovery routing after repeated identical delegation. "
27184
+ f"Validate ONLY the current plan step: {step_text}. "
27185
+ "Worker subtasks are complete and blackboard evidence already exists. "
27186
+ "Run acceptance for this step only, record pass/fix with concrete evidence, and do not jump ahead."
27187
+ ),
27188
+ 1200,
27189
+ )
27190
+ row["reason"] = trim(f"{row.get('reason', '')}|loop-recovery-acceptance", 600)
27191
+ row["source"] = "loop-recovery"
27192
+ row["is_mandatory"] = True
27193
+ return row
27194
+ default_owner = "explorer" if phase in {"research", "design"} else "developer"
27195
+ owner = self._sanitize_agent_role(row.get("target", "")) or self._current_plan_worker_owner(bb)
27196
+ if owner not in {"developer", "explorer"}:
27197
+ owner = default_owner
27198
+ if expected_count > 0 and worker_todo_count == 0:
27199
+ action_text = (
27200
+ "First call TodoWrite for the current plan step and create the missing worker subtasks "
27201
+ "before any more implementation."
27202
+ )
27203
+ elif current_subtask:
27204
+ action_text = f"Continue ONLY the current in_progress subtask: {current_subtask}."
27205
+ elif next_pending:
27206
+ action_text = f"Resume the next pending subtask: {next_pending}."
27207
+ else:
27208
+ action_text = "Continue the current plan step with one concrete tool action."
27209
+ evidence_text = ""
27210
+ if has_evidence:
27211
+ evidence_text = (
27212
+ " Blackboard already contains partial evidence for this step; build on that work instead of restarting."
27213
+ )
27214
+ row["target"] = owner
27215
+ row["instruction"] = trim(
27216
+ (
27217
+ "Recovery routing after repeated identical delegation. "
27218
+ f"Stay on the current plan step: {step_text}. "
27219
+ f"{action_text} "
27220
+ "Do not branch to a different step or restate the whole plan. "
27221
+ "After the subtask is finished, immediately call TodoWrite to mark it completed and move the next subtask to in_progress."
27222
+ f"{evidence_text}"
27223
+ ),
27224
+ 1200,
27225
+ )
27226
+ row["reason"] = trim(f"{row.get('reason', '')}|loop-recovery-execute", 600)
27227
+ row["source"] = "loop-recovery"
27228
+ row["is_mandatory"] = True
27229
+ return row
27230
+
27231
+ def _todo_has_plan_steps(self, board: dict | None = None) -> bool:
27232
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
27233
+ todos = bb.get("project_todos", []) if isinstance(bb.get("project_todos"), list) else []
27234
+ return any(
27235
+ isinstance(todo, dict) and todo.get("category") == "plan_step"
27236
+ for todo in todos
27237
+ )
27238
+
27239
+ def _todo_worker_role_hint(self, role: str = "", board: dict | None = None) -> str:
27240
+ role_key = self._sanitize_agent_role(role)
27241
+ if role_key in {"developer", "explorer", "reviewer"}:
27242
+ return role_key
27243
+ return self._current_plan_worker_owner(board)
27244
+
27245
+ def _todo_route_kind(self, role: str = "", board: dict | None = None) -> str:
27246
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
27247
+ if self._todo_has_plan_steps(bb):
27248
+ return "plan_sync" if self._is_multi_agent_mode() else "plan_single"
27249
+ role_key = self._todo_worker_role_hint(role, bb)
27250
+ if self._is_multi_agent_mode() and role_key in {"developer", "explorer", "reviewer"}:
27251
+ return "pure_sync"
27252
+ return "pure_single"
27253
+
27254
+ def _todo_row_kind(self, row: dict | None) -> str:
27255
+ if not isinstance(row, dict):
27256
+ return ""
27257
+ key = str(row.get("key", "") or "").strip()
27258
+ if key.startswith("bb:"):
27259
+ return "system"
27260
+ owner = str(row.get("owner", "") or "").strip().lower()
27261
+ parent_step_id = str(row.get("parent_step_id", "") or "").strip()
27262
+ if owner in {"developer", "explorer", "reviewer"} and parent_step_id:
27263
+ return "plan_worker"
27264
+ if owner in {"developer", "explorer", "reviewer"}:
27265
+ return "owner_worker"
27266
+ return "flat"
27267
+
27268
+ def _todo_route_rows(
27269
+ self,
27270
+ route_kind: str,
27271
+ *,
27272
+ rows: list[dict] | None = None,
27273
+ role: str = "",
27274
+ board: dict | None = None,
27275
+ ) -> list[dict]:
27276
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
27277
+ snap = [dict(row) for row in (rows if isinstance(rows, list) else self.todo.snapshot()) if isinstance(row, dict)]
27278
+ if route_kind in {"plan_single", "plan_sync"}:
27279
+ step = self._get_active_plan_step(bb)
27280
+ step_id = trim(str((step or {}).get("id", "") or ""), 20)
27281
+ if not step_id:
27282
+ return []
27283
+ return [
27284
+ row for row in snap
27285
+ if self._todo_row_kind(row) == "plan_worker"
27286
+ and str(row.get("parent_step_id", "") or "").strip() == step_id
27287
+ ]
27288
+ if route_kind == "pure_sync":
27289
+ role_key = self._todo_worker_role_hint(role, bb)
27290
+ owner_rows = [row for row in snap if self._todo_row_kind(row) == "owner_worker"]
27291
+ if role_key in {"developer", "explorer", "reviewer"}:
27292
+ scoped = [
27293
+ row for row in owner_rows
27294
+ if str(row.get("owner", "") or "").strip().lower() == role_key
27295
+ ]
27296
+ if scoped:
27297
+ return scoped
27298
+ return owner_rows
27299
+ if route_kind == "pure_single":
27300
+ return [row for row in snap if self._todo_row_kind(row) == "flat"]
27301
+ return []
27302
+
26466
27303
  def _todo_runtime_has_worker_rows(self, role: str = "") -> bool:
26467
- if self._get_active_plan_step() is not None:
26468
- return self._active_plan_step_has_worker_todos(role=role)
26469
- return bool(self.todo.snapshot())
27304
+ route_kind = self._todo_route_kind(role=role)
27305
+ return bool(self._todo_route_rows(route_kind, role=role))
27306
+
27307
+ def _merge_todo_signal_rows(self, items: list[dict], role: str = "", board: dict | None = None) -> str:
27308
+ bb = board if isinstance(board, dict) else self._ensure_blackboard()
27309
+ role_key = self._sanitize_agent_role(role)
27310
+ route_kind = self._todo_route_kind(role=role_key, board=bb)
27311
+ step = self._get_active_plan_step(bb) if route_kind in {"plan_single", "plan_sync"} else None
27312
+ step_id = trim(str((step or {}).get("id", "") or ""), 20)
27313
+ normalized: list[dict] = []
27314
+ for item in items or []:
27315
+ if not isinstance(item, dict):
27316
+ continue
27317
+ row = dict(item)
27318
+ if role_key in {"developer", "explorer", "reviewer"} and not str(row.get("owner", "") or "").strip():
27319
+ row["owner"] = role_key
27320
+ if step_id and not str(row.get("parent_step_id", "") or "").strip():
27321
+ row["parent_step_id"] = step_id
27322
+ normalized.append(row)
27323
+ if not normalized:
27324
+ return self.todo.no_changes_text()
27325
+ if route_kind in {"plan_single", "plan_sync"}:
27326
+ return self._merge_plan_worker_todo_items(normalized, role=role_key)
27327
+ if route_kind == "pure_sync":
27328
+ return self._merge_owner_scoped_todo_items(normalized, role=role_key)
27329
+ return self._merge_flat_todo_items(normalized, role=role_key)
26470
27330
 
26471
27331
  def _plan_worker_todo_identity(self, row: dict | None) -> str:
26472
27332
  import re
@@ -26482,6 +27342,96 @@ body{padding:18px}
26482
27342
  return f"substep:{match.group(1)}"
26483
27343
  return f"text:{content}"
26484
27344
 
27345
+ def _flat_todo_identity(self, row: dict | None) -> str:
27346
+ import re
27347
+
27348
+ if not isinstance(row, dict):
27349
+ return ""
27350
+ key = trim(str(row.get("key", "") or "").strip(), 120)
27351
+ if key.startswith("bb:"):
27352
+ return f"system:{key}"
27353
+ content = normalize_work_text(str(row.get("content", "") or "")) or str(row.get("content", "") or "")
27354
+ content = re.sub(r"\s+", " ", content.strip().lower())
27355
+ if not content:
27356
+ return ""
27357
+ match = re.match(r"^(\d+\.\d+)\b", content)
27358
+ if match:
27359
+ return f"substep:{match.group(1)}"
27360
+ return f"text:{content}"
27361
+
27362
+ def _merge_flat_todo_items(self, items: list[dict], role: str = "") -> str:
27363
+ if not isinstance(items, list):
27364
+ raise ValueError("items must be array")
27365
+ role_key = self._sanitize_agent_role(role)
27366
+ existing = self.todo.snapshot()
27367
+ route_existing = self._todo_route_rows("pure_single", rows=existing, role=role_key)
27368
+ existing_by_identity: dict[str, dict] = {}
27369
+ preserved_system: list[dict] = []
27370
+ for row in existing:
27371
+ if self._todo_row_kind(row) != "system":
27372
+ continue
27373
+ preserved_system.append(dict(row))
27374
+ for row in route_existing:
27375
+ if not isinstance(row, dict):
27376
+ continue
27377
+ identity = self._flat_todo_identity(row)
27378
+ if not identity:
27379
+ continue
27380
+ if identity not in existing_by_identity:
27381
+ existing_by_identity[identity] = dict(row)
27382
+
27383
+ status_alias = {
27384
+ "todo": "pending",
27385
+ "doing": "in_progress",
27386
+ "inprogress": "in_progress",
27387
+ "in-progress": "in_progress",
27388
+ "done": "completed",
27389
+ "finish": "completed",
27390
+ "finished": "completed",
27391
+ }
27392
+ passthrough_rows: list[dict] = []
27393
+ merged_rows: list[dict] = []
27394
+ seen_identities: set[str] = set()
27395
+ for idx, item in enumerate(items):
27396
+ if isinstance(item, str):
27397
+ raw = {"content": item}
27398
+ elif isinstance(item, dict):
27399
+ raw = dict(item)
27400
+ else:
27401
+ raise ValueError(f"item {idx}: invalid type")
27402
+ key = trim(str(raw.get("key", "") or "").strip(), 120)
27403
+ if key.startswith("bb:"):
27404
+ passthrough_rows.append(raw)
27405
+ continue
27406
+ raw_content = str(raw.get("content", raw.get("text", raw.get("title", "")))).strip()
27407
+ content = normalize_work_text(raw_content) or raw_content
27408
+ if not content:
27409
+ continue
27410
+ normalized: dict[str, object] = {"content": content}
27411
+ raw_status = str(raw.get("status", raw.get("state", "")) or "").strip().lower()
27412
+ if raw_status:
27413
+ normalized["status"] = status_alias.get(raw_status, raw_status)
27414
+ owner = str(raw.get("owner", "") or "").strip().lower()
27415
+ if owner in {"manager", "explorer", "developer", "reviewer"}:
27416
+ normalized["owner"] = owner
27417
+ elif role_key == "manager" and owner == "":
27418
+ normalized["owner"] = role_key
27419
+ active_form = str(raw.get("activeForm", raw.get("active_form", "")) or "").strip()
27420
+ if active_form:
27421
+ normalized["activeForm"] = active_form
27422
+ identity = self._flat_todo_identity(normalized)
27423
+ if not identity:
27424
+ identity = f"ad-hoc:{idx}:{trim(content, 80)}"
27425
+ merged = dict(existing_by_identity.get(identity, {}))
27426
+ if "activeForm" not in normalized:
27427
+ merged.pop("activeForm", None)
27428
+ merged.update(normalized)
27429
+ if identity in seen_identities:
27430
+ continue
27431
+ seen_identities.add(identity)
27432
+ merged_rows.append(merged)
27433
+ return self.todo.update(preserved_system + passthrough_rows + merged_rows)
27434
+
26485
27435
  def _merge_plan_worker_todo_items(self, items: list[dict], role: str = "") -> str:
26486
27436
  if not isinstance(items, list):
26487
27437
  raise ValueError("items must be array")
@@ -26499,6 +27449,12 @@ body{padding:18px}
26499
27449
  for row in existing:
26500
27450
  if not isinstance(row, dict):
26501
27451
  continue
27452
+ row_kind = self._todo_row_kind(row)
27453
+ if row_kind == "system":
27454
+ preserved.append(dict(row))
27455
+ continue
27456
+ if row_kind != "plan_worker":
27457
+ continue
26502
27458
  owner = str(row.get("owner", "") or "").strip().lower()
26503
27459
  row_step_id = trim(str(row.get("parent_step_id", "") or ""), 20)
26504
27460
  if owner in worker_owners and row_step_id == step_id:
@@ -26588,7 +27544,16 @@ body{padding:18px}
26588
27544
  and str(row.get("status", "")).lower() != "completed"):
26589
27545
  row.pop("parent_step_id", None) # Not for current step
26590
27546
 
26591
- final_rows = preserved + passthrough_rows + merged_target_rows
27547
+ # Insert merged_target_rows right after the active plan step's bb: row in preserved,
27548
+ # so subtasks appear nested under their parent step rather than at the list bottom.
27549
+ _step_key = str(active_step.get("key", "") or "")
27550
+ _insert_idx = len(preserved) # fallback: append at end
27551
+ if _step_key:
27552
+ for _i, _r in enumerate(preserved):
27553
+ if str(_r.get("key", "") or "") == _step_key:
27554
+ _insert_idx = _i + 1
27555
+ break
27556
+ final_rows = preserved[:_insert_idx] + passthrough_rows + merged_target_rows + preserved[_insert_idx:]
26592
27557
  return self.todo.update(final_rows)
26593
27558
 
26594
27559
  def _merge_owner_scoped_todo_items(self, items: list[dict], role: str = "") -> str:
@@ -26815,7 +27780,7 @@ body{padding:18px}
26815
27780
  content = self._build_plan_todo_reminder_text(plan_step, missing_subtasks=missing_subtasks)
26816
27781
  if not content:
26817
27782
  return False
26818
- self.messages.append({"role": "user", "content": content, "ts": now_tick})
27783
+ self._append_plan_guidance_bubble(content, summary="todo reminder")
26819
27784
  self.last_todo_reminder_ts = now_tick
26820
27785
  self.todo_reminder_count += 1
26821
27786
  self._emit(
@@ -26834,7 +27799,7 @@ body{padding:18px}
26834
27799
 
26835
27800
  if not isinstance(plan_step, dict):
26836
27801
  return []
26837
- raw = str(plan_step.get("full_content", "") or plan_step.get("content", "") or "")
27802
+ raw = normalize_embedded_newlines(plan_step.get("full_content", "") or plan_step.get("content", "") or "")
26838
27803
  if not raw.strip():
26839
27804
  return []
26840
27805
  lines = [trim(str(line or "").strip(), 300) for line in raw.replace("\r\n", "\n").split("\n")]
@@ -26964,6 +27929,136 @@ body{padding:18px}
26964
27929
  self.todo.items = preserved + replacement
26965
27930
  return True
26966
27931
 
27932
+ def _check_step_verified_tag(self, plan_step: dict, *, messages: list | None = None) -> bool:
27933
+ """Return True if the agent has emitted <step-verified> in any assistant message
27934
+ since this plan step was activated (i.e., after plan_step['activated_at']).
27935
+ Pass messages=self.agent_messages for sync mode; defaults to self.messages."""
27936
+ activated_at = float(plan_step.get("activated_at", 0.0) or 0.0)
27937
+ msg_list = messages if messages is not None else self.messages
27938
+ for msg in reversed(msg_list):
27939
+ if not isinstance(msg, dict):
27940
+ continue
27941
+ msg_ts = float(msg.get("ts", 0.0) or 0.0)
27942
+ # Stop once we reach messages predating step activation
27943
+ if activated_at > 0 and msg_ts > 0 and msg_ts < activated_at:
27944
+ break
27945
+ if msg.get("role") == "assistant":
27946
+ content = str(msg.get("content", "") or "")
27947
+ if "<step-verified" in content:
27948
+ return True
27949
+ return False
27950
+
27951
+ def _single_mode_validation_gate(self, plan_step: dict, tool_results: list[dict]) -> bool:
27952
+ """Gate passes when:
27953
+ 1. Phase is research/design (no execution needed)
27954
+ 2. Model emitted <step-verified/> since step activation
27955
+ 3. Blackboard shows phase-appropriate accumulated evidence (has_write+has_exec for implement)
27956
+ 4. Escape hatch: 10 consecutive blocks
27957
+ Research/design phases exempt. Escape hatch prevents permanent stall."""
27958
+ step_id = str(plan_step.get("id", "") or "")
27959
+ _flag = f"_smvg_{step_id}"
27960
+ if getattr(self, _flag, False):
27961
+ return True # Already validated in a previous round
27962
+ step_content = str(plan_step.get("full_content", "") or plan_step.get("content", "") or "").lower()
27963
+ phase = self._plan_step_phase_hint(step_content)
27964
+ if phase in ("research", "design"):
27965
+ setattr(self, _flag, True)
27966
+ return True # No verification needed for non-execution phases
27967
+ # Escape hatch: after 10 consecutive blocks, unblock to prevent permanent stall
27968
+ _n_flag = f"_smvg_n_{step_id}"
27969
+ _n_blocked = int(getattr(self, _n_flag, 0))
27970
+ if _n_blocked >= 10:
27971
+ setattr(self, _flag, True)
27972
+ return True
27973
+ # Path A: model explicit tag
27974
+ if self._check_step_verified_tag(plan_step):
27975
+ setattr(self, _flag, True)
27976
+ return True
27977
+ # Path B: blackboard shows phase-appropriate accumulated evidence
27978
+ # implement: has_write AND (has_exec OR ...) — wrote files + ran bash
27979
+ # test/review: has_exec OR has_test_pass — ran tests
27980
+ # other: use generic evidence check
27981
+ if self._plan_step_has_blackboard_evidence(plan_step):
27982
+ setattr(self, _flag, True)
27983
+ return True
27984
+ # Gate blocked — increment counter and inject hint
27985
+ setattr(self, _n_flag, _n_blocked + 1)
27986
+ self._inject_single_mode_validation_hint(plan_step)
27987
+ return False
27988
+
27989
+ def _inject_single_mode_validation_hint(self, plan_step: dict):
27990
+ """Inject a hint (rate-limited 20s) instructing the model to emit <step-verified/>
27991
+ after evaluating bash output against the step's acceptance criteria."""
27992
+ step_id = str(plan_step.get("id", "") or "")
27993
+ _ts_flag = f"_smvg_ts_{step_id}"
27994
+ _last_ts = float(getattr(self, _ts_flag, 0.0))
27995
+ if float(now_ts()) - _last_ts < 20.0:
27996
+ return
27997
+ setattr(self, _ts_flag, float(now_ts()))
27998
+ step_label = trim(str(plan_step.get("content", "") or ""), 80)
27999
+ full_content = str(plan_step.get("full_content", "") or plan_step.get("content", "") or "")
28000
+ # Extract ACCEPTANCE criteria line if present
28001
+ acceptance = ""
28002
+ for line in full_content.splitlines():
28003
+ if line.strip().upper().startswith("ACCEPTANCE:"):
28004
+ acceptance = line.strip()[len("ACCEPTANCE:"):].strip()
28005
+ break
28006
+ phase = self._plan_step_phase_hint(full_content.lower())
28007
+ if phase == "test":
28008
+ action = "run the tests with bash and evaluate the results"
28009
+ else:
28010
+ action = "run the build/compile/run command with bash and evaluate the output"
28011
+ accept_line = f"\nAcceptance criteria: {acceptance}" if acceptance else ""
28012
+ msg = (
28013
+ f"<verification-required>\n"
28014
+ f"All subtasks for \"{step_label}\" are marked complete.{accept_line}\n"
28015
+ f"Before this step can advance, you must:\n"
28016
+ f"1. {action}\n"
28017
+ f"2. Review the bash output and confirm it meets the acceptance criteria\n"
28018
+ f"3. If it passes, emit exactly: <step-verified/>\n"
28019
+ f"4. If it fails, fix the issue and retry — do NOT emit <step-verified/> until resolved\n"
28020
+ f"</verification-required>"
28021
+ )
28022
+ _recent = self.messages[-5:]
28023
+ if not any("<verification-required>" in str(m.get("content", "") or "") for m in _recent if isinstance(m, dict)):
28024
+ self.messages.append({"role": "user", "content": msg, "ts": now_ts()})
28025
+
28026
+ def _inject_sync_mode_verification_hint(self, plan_step: dict, worker_step: dict):
28027
+ """Inject a verification hint into agent_messages (rate-limited 30s) for sync mode.
28028
+ Instructs the worker to emit <step-verified/> after evaluating bash output."""
28029
+ step_id = str(plan_step.get("id", "") or "")
28030
+ _ts_flag = f"_sync_sv_ts_{step_id}"
28031
+ _last_ts = float(getattr(self, _ts_flag, 0.0))
28032
+ if float(now_ts()) - _last_ts < 30.0:
28033
+ return
28034
+ setattr(self, _ts_flag, float(now_ts()))
28035
+ step_label = trim(str(plan_step.get("content", "") or ""), 80)
28036
+ full_content = str(plan_step.get("full_content", "") or plan_step.get("content", "") or "")
28037
+ acceptance = ""
28038
+ for line in full_content.splitlines():
28039
+ if line.strip().upper().startswith("ACCEPTANCE:"):
28040
+ acceptance = line.strip()[len("ACCEPTANCE:"):].strip()
28041
+ break
28042
+ phase = self._plan_step_phase_hint(full_content.lower())
28043
+ if phase == "test":
28044
+ action = "run the tests with bash and evaluate results"
28045
+ else:
28046
+ action = "run the build/compile command with bash and evaluate the output"
28047
+ accept_line = f"\nAcceptance criteria: {acceptance}" if acceptance else ""
28048
+ msg = (
28049
+ f"<verification-required>\n"
28050
+ f"All subtasks for \"{step_label}\" are marked complete.{accept_line}\n"
28051
+ f"Before this step can advance:\n"
28052
+ f"1. {action}\n"
28053
+ f"2. Review the output and confirm acceptance criteria are met\n"
28054
+ f"3. If it passes, emit exactly: <step-verified/>\n"
28055
+ f"4. If it fails, fix and retry — do NOT emit <step-verified/> until resolved\n"
28056
+ f"</verification-required>"
28057
+ )
28058
+ _recent = self.agent_messages[-5:]
28059
+ if not any("<verification-required>" in str(m.get("content", "") or "") for m in _recent if isinstance(m, dict)):
28060
+ self.agent_messages.append({"role": "user", "content": msg, "ts": now_ts()})
28061
+
26967
28062
  def _single_agent_plan_step_check(self, tool_results: list[dict]):
26968
28063
  """In single-agent mode, check if current plan step should be advanced based on tool results."""
26969
28064
  bb = self._ensure_blackboard()
@@ -26981,6 +28076,24 @@ body{padding:18px}
26981
28076
  if not current:
26982
28077
  self._sync_todos_from_blackboard(reason="single-agent-round")
26983
28078
  return
28079
+ # When a new step is activated with no subtasks yet, require TodoWrite first
28080
+ _cur_step_id = str(current.get("id", "") or "")
28081
+ if _cur_step_id:
28082
+ _existing_subs = self._active_plan_worker_todo_rows(_cur_step_id, role="")
28083
+ if not _existing_subs:
28084
+ _step_label_s = trim(str(current.get("content", "") or ""), 60)
28085
+ _force_tw_msg = (
28086
+ f"<action-required>\n"
28087
+ f"Step \"{_step_label_s}\" has no subtasks yet. "
28088
+ f"Your FIRST action MUST be to call TodoWrite with "
28089
+ f"parent_step_id=\"{_cur_step_id}\" to create this step's subtasks "
28090
+ f"(e.g. N.1, N.2 ...) before executing any other work.\n"
28091
+ f"</action-required>"
28092
+ )
28093
+ _recent_msgs = self.messages[-4:]
28094
+ if not any("<action-required>" in str(m.get("content", "") or "") for m in _recent_msgs if isinstance(m, dict)):
28095
+ self._append_plan_guidance_bubble(_force_tw_msg, summary="action required: create subtasks first")
28096
+ return # Wait for TodoWrite before doing other checks
26984
28097
  # Heuristic: check if tool results indicate step completion
26985
28098
  step_content = str(current.get("full_content", "") or current.get("content", "") or "").lower()
26986
28099
  phase = self._plan_step_phase_hint(step_content)
@@ -26996,25 +28109,29 @@ body{padding:18px}
26996
28109
  validation_ok_blackboard = self._plan_step_has_blackboard_evidence(current, bb)
26997
28110
  validation_ok = validation_ok_current or validation_ok_blackboard
26998
28111
  bb_sig = self._plan_step_blackboard_signals(current, bb)
28112
+ todo_progress_signal = any(
28113
+ isinstance(r, dict) and r.get("ok", False)
28114
+ and str(r.get("name", "")) in ("TodoWrite", "TodoWriteRescue")
28115
+ for r in tool_results
28116
+ )
26999
28117
  # Auto-advance conditions:
27000
28118
  should_advance = False
28119
+ _gate_blocked = False # True when validation gate fired and blocked — no other path may advance
27001
28120
  # Priority 1: Check if worker subtasks are all completed (most reliable signal)
27002
28121
  subtasks_done = self._step_subtasks_all_completed(current)
27003
- if subtasks_done and validation_ok:
27004
- should_advance = True
27005
- # Fix 3 (single mode): Accumulated evidence path subtasks done + accumulated evidence
27006
- # Covers TodoWrite-only turns where validation_ok_current is False
27007
- if not should_advance and subtasks_done:
27008
- todo_progress_signal = any(
27009
- isinstance(r, dict) and r.get("ok", False)
27010
- and str(r.get("name", "")) in ("TodoWrite", "TodoWriteRescue")
27011
- for r in tool_results
27012
- )
27013
- if todo_progress_signal and self._step_has_accumulated_evidence(current, bb):
28122
+ if subtasks_done:
28123
+ # Validation gate: passes when model emitted <step-verified/>, blackboard has
28124
+ # phase-appropriate evidence, or escape hatch (10 blocks). Gate is the authoritative
28125
+ # "step done" check no additional validation_ok required after gate passes.
28126
+ _gate_ok = self._single_mode_validation_gate(current, tool_results)
28127
+ if _gate_ok:
27014
28128
  should_advance = True
28129
+ else:
28130
+ _gate_blocked = True # Gate blocked — disable ALL remaining advancement paths
27015
28131
  # Priority 2: Phase-based heuristics — BUT gate by subtask completion when subtasks exist
27016
28132
  # CRITICAL: A single write_file must NOT advance when 3+ subtasks remain
27017
- if not should_advance:
28133
+ # Skipped when validation gate has blocked advancement (subtasks_done + gate failed)
28134
+ if not should_advance and not _gate_blocked:
27018
28135
  _has_subtasks_s = bool(self._active_plan_worker_todo_rows(
27019
28136
  str(current.get("id", "") or ""), role=""
27020
28137
  ))
@@ -27033,7 +28150,8 @@ body{padding:18px}
27033
28150
  ):
27034
28151
  should_advance = True
27035
28152
  # Also check if the agent explicitly mentioned step completion
27036
- if not should_advance:
28153
+ # Also blocked by validation gate when subtasks_done path was blocked
28154
+ if not should_advance and not _gate_blocked:
27037
28155
  # Check last assistant message for step completion signals
27038
28156
  last_text = ""
27039
28157
  for msg in reversed(self.messages[-3:]):
@@ -27041,7 +28159,8 @@ body{padding:18px}
27041
28159
  last_text = str(msg.get("content", "") or "").lower()
27042
28160
  break
27043
28161
  step_done_signals = ("step completed", "步骤完成", "step done", "完成了", "已完成",
27044
- "next step", "下一步", "proceed to step", "进入下一")
28162
+ "next step", "下一步", "proceed to step", "进入下一",
28163
+ "全部完成", "✅", "all subtasks")
27045
28164
  if validation_ok and any(sig in last_text for sig in step_done_signals):
27046
28165
  should_advance = True
27047
28166
  if should_advance:
@@ -27054,6 +28173,23 @@ body{padding:18px}
27054
28173
  else:
27055
28174
  self._inject_rework_if_needed(current, {"tool_results": tool_results})
27056
28175
  self._sync_todos_from_blackboard(reason="single-agent-round")
28176
+ if todo_progress_signal and not subtasks_done:
28177
+ step_rows = self._active_plan_worker_todo_rows(str(current.get("id", "") or ""), role="")
28178
+ next_row = next(
28179
+ (r for r in step_rows if str(r.get("status", "") or "").strip().lower() == "in_progress"),
28180
+ None,
28181
+ )
28182
+ focus_text = trim(str((next_row or {}).get("content", "") or "").strip(), 180)
28183
+ if focus_text:
28184
+ focus_msg = (
28185
+ "<todo-focus>"
28186
+ f"Continue ONLY the current in_progress subtask: {focus_text}. "
28187
+ "Do not branch away from the active plan step."
28188
+ "</todo-focus>"
28189
+ )
28190
+ recent = self.messages[-6:]
28191
+ if not any(str(msg.get("content", "") or "").strip() == focus_msg for msg in recent if isinstance(msg, dict)):
28192
+ self._append_plan_guidance_bubble(focus_msg, summary="todo focus: continue current subtask")
27057
28193
 
27058
28194
  def _todo_project_rows_from_blackboard(self, board: dict | None = None) -> list[dict]:
27059
28195
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
@@ -27063,7 +28199,9 @@ body{padding:18px}
27063
28199
  rows = []
27064
28200
  for todo in todos:
27065
28201
  s = todo.get("status", "pending")
27066
- c = todo.get("content", "")
28202
+ c = normalize_embedded_newlines(todo.get("content", "") or "")
28203
+ if str(todo.get("category", "") or "") == "plan_step" and "\n" in c:
28204
+ c = c.split("\n", 1)[0].strip()
27067
28205
  ev = todo.get("evidence", "")
27068
28206
  af = {
27069
28207
  "in_progress": self._ui_text("todo_working", content=c),
@@ -27076,12 +28214,9 @@ body{padding:18px}
27076
28214
  if bool(self.runtime_reclassify_required):
27077
28215
  return
27078
28216
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
27079
- # In single mode, still sync plan_step todos if they exist
27080
- has_plan_steps = any(
27081
- isinstance(t, dict) and t.get("category") == "plan_step"
27082
- for t in (bb.get("project_todos", []) if isinstance(bb.get("project_todos"), list) else [])
27083
- )
27084
- if not self._is_multi_agent_mode() and not has_plan_steps:
28217
+ route_kind = self._todo_route_kind(board=bb)
28218
+ has_plan_steps = route_kind in {"plan_single", "plan_sync"}
28219
+ if route_kind == "pure_single":
27085
28220
  return
27086
28221
  self._init_project_todos(bb)
27087
28222
  self._update_project_todo_status(bb)
@@ -27093,25 +28228,25 @@ body{padding:18px}
27093
28228
  pass
27094
28229
  system_rows = self._todo_project_rows_from_blackboard(bb)
27095
28230
  existing = self.todo.snapshot()
28231
+ bridged_flat_rows = False
27096
28232
  worker_rows: list[dict] = []
27097
28233
  non_system_rows: list[dict] = []
27098
- for row in existing:
27099
- if not isinstance(row, dict):
27100
- continue
27101
- key = str(row.get("key", "") or "").strip()
27102
- owner = str(row.get("owner", "") or "").strip().lower()
27103
- is_system_key = key.startswith(("bb:owner:", "bb:node:", "bb:proj:"))
27104
- # Preserve worker-owned items (from TodoWrite) separately
27105
- is_worker_item = owner in ("developer", "explorer", "reviewer") and not is_system_key
27106
- if is_worker_item:
27107
- worker_rows.append(dict(row))
27108
- continue
27109
- if is_system_key or owner == "manager":
27110
- continue
27111
- non_system_rows.append(dict(row))
27112
- if has_plan_steps:
27113
- worker_rows = [r for r in worker_rows if str(r.get("parent_step_id", "") or "").strip()]
27114
- non_system_rows = []
28234
+ if route_kind == "plan_single":
28235
+ worker_rows = self._todo_route_rows(route_kind, rows=existing, board=bb)
28236
+ if not worker_rows:
28237
+ flat_rows = self._todo_route_rows("pure_single", rows=existing, board=bb)
28238
+ bridged_rows, bridged_flat_rows = self._bridge_flat_todos_to_active_plan_step(flat_rows, board=bb)
28239
+ if bridged_flat_rows:
28240
+ worker_rows = self._todo_route_rows(route_kind, rows=bridged_rows, board=bb)
28241
+ elif route_kind == "plan_sync":
28242
+ worker_rows = self._todo_route_rows(route_kind, rows=existing, board=bb)
28243
+ elif route_kind == "pure_sync":
28244
+ worker_rows = self._todo_route_rows(
28245
+ route_kind,
28246
+ rows=existing,
28247
+ role=self._todo_worker_role_hint(board=bb),
28248
+ board=bb,
28249
+ )
27115
28250
  # Smart trim: keep all active (in_progress/pending) system rows,
27116
28251
  # but only recent 3 completed system rows to save capacity for worker subtasks
27117
28252
  active_system = [r for r in system_rows if r.get("status") != "completed"]
@@ -27175,6 +28310,11 @@ body{padding:18px}
27175
28310
  todo_out = self.todo.update(merged)
27176
28311
  except Exception:
27177
28312
  return
28313
+ if bridged_flat_rows:
28314
+ self._emit(
28315
+ "status",
28316
+ {"summary": "flat todos attached to current plan step"},
28317
+ )
27178
28318
  if todo_out != self.todo.no_changes_text() and reason:
27179
28319
  self._emit(
27180
28320
  "status",
@@ -27507,7 +28647,7 @@ body{padding:18px}
27507
28647
  task_type = trim(str(row.get("task_type", "") or "").strip().lower(), 40)
27508
28648
  if task_type in TASK_PROFILE_TYPES:
27509
28649
  merged["task_type"] = task_type
27510
- complexity = trim(str(row.get("complexity", "") or "").strip().lower(), 20)
28650
+ complexity = normalize_task_complexity(row.get("complexity", ""), default="")
27511
28651
  if complexity in TASK_COMPLEXITY_LEVELS:
27512
28652
  merged["complexity"] = complexity
27513
28653
  scale = trim(str(row.get("scale_preference", "") or "").strip().lower(), 20)
@@ -27552,7 +28692,7 @@ body{padding:18px}
27552
28692
  def _fallback_task_level_decision(self, goal_text: str) -> dict:
27553
28693
  profile = self._infer_task_profile(goal_text)
27554
28694
  task_type = str(profile.get("task_type", "general") or "general")
27555
- complexity = str(profile.get("complexity", "simple") or "simple")
28695
+ complexity = normalize_task_complexity(profile.get("complexity", "simple"), default="simple")
27556
28696
  low = str(goal_text or "").lower()
27557
28697
  inherit_previous_state = False
27558
28698
  if bool(self.runtime_goal_reset_pending):
@@ -27671,9 +28811,9 @@ body{padding:18px}
27671
28811
  level = 3
27672
28812
  if task_type == "simple_qa":
27673
28813
  level = 1 if len(str(goal_text or "")) <= 180 else 2
27674
- elif complexity == "simple" and task_type in {"general"}:
28814
+ elif task_complexity_rank(complexity) <= task_complexity_rank("simple") and task_type in {"general"}:
27675
28815
  level = 2
27676
- elif complexity == "simple":
28816
+ elif task_complexity_rank(complexity) <= task_complexity_rank("moderate"):
27677
28817
  level = 3
27678
28818
  elif any(tok in low for tok in ("system-level", "系统级", "blackboard", "orchestrator", "内核", "基础设施")):
27679
28819
  level = 5
@@ -27735,7 +28875,9 @@ body{padding:18px}
27735
28875
  "SCALE PREFERENCE: Infer fast|balanced|thorough from user wording. "
27736
28876
  "User-stated preference overrides your default strategy. "
27737
28877
  "Budget controls internal depth/compactness, NOT early-stop messaging to user.\n\n"
27738
- "Output exactly one classify_task_level tool call with concise judgement, inherit_previous_state, "
28878
+ "CRITICAL OUTPUT CONTRACT: You MUST output exactly one classify_task_level tool call and no plain-text answer. "
28879
+ "A prose-only response is invalid and will be discarded.\n"
28880
+ "The tool call must include concise judgement, inherit_previous_state, "
27739
28881
  "and semantic_confidence (high|medium|low). "
27740
28882
  "Use low confidence only when semantic ambiguity is substantial, then set low_confidence_reason briefly.\n"
27741
28883
  f"{model_language_instruction(self.ui_language)}"
@@ -27747,6 +28889,28 @@ body{padding:18px}
27747
28889
  )
27748
28890
  return base
27749
28891
 
28892
+ def _extract_classify_task_level_row(self, response: dict | None) -> dict:
28893
+ if not isinstance(response, dict):
28894
+ return {}
28895
+ tool_calls = response.get("tool_calls", []) if isinstance(response.get("tool_calls", []), list) else []
28896
+ for tc in tool_calls:
28897
+ fn = tc.get("function", {}) if isinstance(tc, dict) else {}
28898
+ if str(fn.get("name", "") or "").strip() != "classify_task_level":
28899
+ continue
28900
+ args = fn.get("arguments", {}) if isinstance(fn, dict) else {}
28901
+ if isinstance(args, dict):
28902
+ return dict(args)
28903
+ if isinstance(args, str):
28904
+ parsed, _ = parse_tool_arguments_with_error(args)
28905
+ if isinstance(parsed, dict):
28906
+ return dict(parsed)
28907
+ content = str(response.get("content", "") or "").strip()
28908
+ if content:
28909
+ parsed, _ = parse_tool_arguments_with_error(content)
28910
+ if isinstance(parsed, dict) and parsed.get("level") is not None:
28911
+ return dict(parsed)
28912
+ return {}
28913
+
27750
28914
  def _skill_aware_reeval_task_level(
27751
28915
  self,
27752
28916
  goal_text: str,
@@ -27940,7 +29104,7 @@ body{padding:18px}
27940
29104
  if low_confidence_mode:
27941
29105
  rule_profile = self._infer_task_profile(goal_text)
27942
29106
  fallback_task_type = str(rule_profile.get("task_type", "general") or "general")
27943
- fallback_complexity = str(rule_profile.get("complexity", "simple") or "simple")
29107
+ fallback_complexity = normalize_task_complexity(rule_profile.get("complexity", "simple"), default="simple")
27944
29108
  fallback_objective = trim(str(rule_profile.get("direct_objective", "") or ""), 800)
27945
29109
  else:
27946
29110
  board_now = self._ensure_blackboard()
@@ -27951,12 +29115,10 @@ body{padding:18px}
27951
29115
  )
27952
29116
  if fallback_task_type not in TASK_PROFILE_TYPES:
27953
29117
  fallback_task_type = "general"
27954
- fallback_complexity = trim(
27955
- str(self.runtime_task_complexity or board_profile.get("complexity", "simple") or "simple"),
27956
- 20,
29118
+ fallback_complexity = normalize_task_complexity(
29119
+ self.runtime_task_complexity or board_profile.get("complexity", "simple") or "simple",
29120
+ default="simple",
27957
29121
  )
27958
- if fallback_complexity not in TASK_COMPLEXITY_LEVELS:
27959
- fallback_complexity = "simple"
27960
29122
  fallback_objective = trim(
27961
29123
  str(self.runtime_direct_objective or board_profile.get("direct_objective", "") or "").strip(),
27962
29124
  800,
@@ -27968,22 +29130,20 @@ body{padding:18px}
27968
29130
  task_type = trim(str(row.get("task_type", "") or "").strip().lower(), 40)
27969
29131
  if task_type not in TASK_PROFILE_TYPES:
27970
29132
  task_type = fallback_task_type
27971
- complexity = trim(str(row.get("complexity", "") or "").strip().lower(), 20)
27972
- if complexity not in TASK_COMPLEXITY_LEVELS:
27973
- complexity = fallback_complexity
29133
+ complexity = normalize_task_complexity(row.get("complexity", ""), default=fallback_complexity)
27974
29134
  if explicit_complexity in TASK_COMPLEXITY_LEVELS:
27975
- complexity = explicit_complexity
29135
+ complexity = normalize_task_complexity(explicit_complexity, default=fallback_complexity)
27976
29136
  elif preserve_existing_complexity and previous_complexity in TASK_COMPLEXITY_LEVELS:
27977
- complexity = previous_complexity
29137
+ complexity = normalize_task_complexity(previous_complexity, default=fallback_complexity)
27978
29138
  low_confidence_reason = trim(str(row.get("low_confidence_reason", "") or "").strip(), 220)
27979
29139
  judgement = trim(str(row.get("judgement", "") or "").strip(), 200) or "manager classified task level"
27980
29140
  objective = trim(str(row.get("direct_objective", "") or "").strip(), 800)
27981
29141
  if not objective:
27982
29142
  objective = fallback_objective
27983
29143
  _prev_level_val = int(getattr(self, '_prev_applied_task_level', 0) or 0)
27984
- _complexity_floor = str(getattr(self, 'runtime_complexity_floor', '') or '').strip()
27985
- if _complexity_floor == "complex" and complexity == "simple":
27986
- complexity = "complex"
29144
+ _complexity_floor = normalize_task_complexity(getattr(self, 'runtime_complexity_floor', '') or '', default="")
29145
+ if _complexity_floor in TASK_COMPLEXITY_LEVELS and task_complexity_rank(complexity) < task_complexity_rank(_complexity_floor):
29146
+ complexity = _complexity_floor
27987
29147
  self.runtime_task_level = int(level)
27988
29148
  self._prev_applied_task_level = int(level)
27989
29149
  self.runtime_execution_mode = mode
@@ -28175,34 +29335,50 @@ body{padding:18px}
28175
29335
  retries=max(1, int(MODEL_OUTPUT_RETRY_TIMES)),
28176
29336
  media_inputs=media_inputs_round,
28177
29337
  )
28178
- tool_calls = response.get("tool_calls", []) if isinstance(response, dict) else []
28179
- for tc in tool_calls or []:
28180
- fn = tc.get("function", {}) if isinstance(tc, dict) else {}
28181
- if str(fn.get("name", "") or "").strip() != "classify_task_level":
28182
- continue
28183
- args = fn.get("arguments", {}) if isinstance(fn, dict) else {}
28184
- if isinstance(args, dict):
28185
- row = dict(args)
28186
- row["inherit_previous_state"] = _to_bool_like(
28187
- row.get("inherit_previous_state", False),
28188
- default=False,
28189
- )
28190
- row["semantic_confidence"] = self._normalize_semantic_confidence(
28191
- row.get("semantic_confidence", "medium"),
28192
- default="medium",
28193
- )
28194
- if str(row.get("semantic_confidence", "medium")) == "low":
28195
- # Skill-aware re-evaluation before falling back to keyword heuristic
28196
- reeval_row = self._skill_aware_reeval_task_level(goal_text, row, pinned_selection)
28197
- fallback_row = self._fallback_task_level_decision(goal_text)
28198
- merged = self._merge_task_decision_for_low_confidence(reeval_row, fallback_row)
28199
- return merged
28200
- row["source"] = "manager"
28201
- return row
29338
+ row = self._extract_classify_task_level_row(response)
29339
+ if not row:
29340
+ repair_prompt = (
29341
+ "Previous answer was invalid because it did not produce a valid classify_task_level tool call. "
29342
+ "Retry now. Output exactly one classify_task_level tool call and no prose."
29343
+ )
29344
+ repair_response = self._chat_with_same_model_retry(
29345
+ [
29346
+ {"role": "user", "content": prompt, "ts": now_ts()},
29347
+ {"role": "user", "content": repair_prompt, "ts": now_ts()},
29348
+ ],
29349
+ tools=self._manager_task_classify_tools(),
29350
+ system=self._manager_classification_system_prompt(),
29351
+ max_tokens=220,
29352
+ think=False,
29353
+ stream_thinking=False,
29354
+ on_thinking_chunk=self._append_live_thinking,
29355
+ pinned_selection=pinned_selection,
29356
+ context_label="manager classify repair",
29357
+ retries=1,
29358
+ media_inputs=media_inputs_round,
29359
+ )
29360
+ row = self._extract_classify_task_level_row(repair_response)
29361
+ if row:
29362
+ row["inherit_previous_state"] = _to_bool_like(
29363
+ row.get("inherit_previous_state", False),
29364
+ default=False,
29365
+ )
29366
+ row["semantic_confidence"] = self._normalize_semantic_confidence(
29367
+ row.get("semantic_confidence", "medium"),
29368
+ default="medium",
29369
+ )
29370
+ if str(row.get("semantic_confidence", "medium")) == "low":
29371
+ # Skill-aware re-evaluation before falling back to keyword heuristic
29372
+ reeval_row = self._skill_aware_reeval_task_level(goal_text, row, pinned_selection)
29373
+ fallback_row = self._fallback_task_level_decision(goal_text)
29374
+ merged = self._merge_task_decision_for_low_confidence(reeval_row, fallback_row)
29375
+ return merged
29376
+ row["source"] = "manager"
29377
+ return row
28202
29378
  row = self._fallback_task_level_decision(goal_text)
28203
29379
  row["source"] = "fallback-no-toolcall"
28204
29380
  row["semantic_confidence"] = "low"
28205
- row["low_confidence_reason"] = "manager classifier returned no valid tool call"
29381
+ row["low_confidence_reason"] = "manager classifier returned no valid classify_task_level tool call"
28206
29382
  return row
28207
29383
 
28208
29384
  # ------------------------------------------------------------------
@@ -29010,7 +30186,7 @@ body{padding:18px}
29010
30186
  "reason": "conclusive-reply-detected",
29011
30187
  "source": "fallback",
29012
30188
  }
29013
- if complexity == "simple" and task_type == "simple_code":
30189
+ if task_complexity_rank(complexity) <= task_complexity_rank("moderate") and task_type == "simple_code":
29014
30190
  if has_error_log:
29015
30191
  return {
29016
30192
  "target": "developer",
@@ -29148,6 +30324,10 @@ body{padding:18px}
29148
30324
  task_type_low = str(row.get("task_type", "") or "").strip().lower()
29149
30325
  # 5a: Merge in-memory routes with persisted routes for detection
29150
30326
  bb_for_routes = self._ensure_blackboard()
30327
+ current_progress_fp = self._watchdog_state_fingerprint(bb_for_routes)
30328
+ last_delegate = bb_for_routes.get("last_delegate", {}) if isinstance(bb_for_routes.get("last_delegate"), dict) else {}
30329
+ last_progress_fp = trim(str(last_delegate.get("progress_fp", "") or "").strip(), 80)
30330
+ no_progress_since_last_delegate = bool(last_progress_fp and last_progress_fp == current_progress_fp)
29151
30331
  persisted_routes = bb_for_routes.get("persisted_manager_routes", [])
29152
30332
  if not isinstance(persisted_routes, list):
29153
30333
  persisted_routes = []
@@ -29159,22 +30339,16 @@ body{padding:18px}
29159
30339
  if (
29160
30340
  isinstance(deleg, dict)
29161
30341
  and str(deleg.get("target", "") or "").strip().lower() == target
30342
+ and (
30343
+ not str(deleg.get("progress_fp", "") or "").strip()
30344
+ or str(deleg.get("progress_fp", "") or "").strip() == current_progress_fp
30345
+ )
29162
30346
  and int(deleg.get("count", 0) or 0) >= 3
29163
30347
  ):
29164
- alt_targets = [r for r in ("reviewer", "developer", "explorer") if r != target]
29165
- if len(bb_for_routes.get("code_artifacts", {}) or {}) > 0:
29166
- row["target"] = "finish"
29167
- row["instruction"] = (
29168
- f"Anti-stall: delegation to '{target}' repeated {deleg.get('count')} times with same instruction. "
29169
- "Forcing finish to break loop."
29170
- )
29171
- else:
29172
- row["target"] = alt_targets[0] if alt_targets else "developer"
29173
- row["instruction"] = (
29174
- f"Anti-stall: delegation to '{target}' repeated {deleg.get('count')} times. "
29175
- f"Switching to {row['target']} with fresh approach."
29176
- )
29177
- row["reason"] = f"{row.get('reason', '')}|anti-stall-repeated-delegation"
30348
+ if not no_progress_since_last_delegate:
30349
+ continue
30350
+ row = self._manager_recovery_route_for_repeated_delegate(row, board=bb_for_routes)
30351
+ row["reason"] = trim(f"{row.get('reason', '')}|anti-stall-repeated-delegation", 600)
29178
30352
  row["source"] = "anti-stall"
29179
30353
  return row
29180
30354
  if task_type_low in ("simple_code", "engineering") and target == "explorer":
@@ -29197,7 +30371,7 @@ body{padding:18px}
29197
30371
  if target not in AGENT_ROLES:
29198
30372
  return row
29199
30373
  recent = [str(x.get("target", "") or "").strip().lower() for x in merged_routes[-4:]]
29200
- if len(recent) >= 3 and recent[-1] == target and recent[-2] == target and recent[-3] == target:
30374
+ if no_progress_since_last_delegate and len(recent) >= 3 and recent[-1] == target and recent[-2] == target and recent[-3] == target:
29201
30375
  board = bb_for_routes
29202
30376
  low_reason = str(row.get("reason", "") or "").strip().lower()
29203
30377
  if "summary" in low_reason and len(board.get("code_artifacts", {}) or {}) > 0:
@@ -29242,7 +30416,7 @@ body{padding:18px}
29242
30416
  row["reason"] = f"{row.get('reason', '')}|anti-stall->developer-suggest"
29243
30417
  row["source"] = "anti-stall"
29244
30418
  return row
29245
- if len(recent) == 4 and recent[0] == recent[2] and recent[1] == recent[3] and recent[0] != recent[1]:
30419
+ if no_progress_since_last_delegate and len(recent) == 4 and recent[0] == recent[2] and recent[1] == recent[3] and recent[0] != recent[1]:
29246
30420
  board = bb_for_routes
29247
30421
  if len(board.get("code_artifacts", {}) or {}) > 0:
29248
30422
  row["target"] = "finish"
@@ -29323,9 +30497,7 @@ body{padding:18px}
29323
30497
  task_type = trim(str(row.get("task_type", default_type) or "").strip().lower(), 40) or default_type
29324
30498
  if task_type not in TASK_PROFILE_TYPES:
29325
30499
  task_type = default_type
29326
- complexity = trim(str(row.get("complexity", default_complexity) or "").strip().lower(), 20) or default_complexity
29327
- if complexity not in TASK_COMPLEXITY_LEVELS:
29328
- complexity = default_complexity
30500
+ complexity = normalize_task_complexity(row.get("complexity", default_complexity) or default_complexity, default=default_complexity)
29329
30501
  scale_preference = trim(
29330
30502
  str(row.get("scale_preference", profile.get("scale_preference", self.runtime_scale_preference)) or "").strip().lower(),
29331
30503
  20,
@@ -30116,6 +31288,7 @@ body{padding:18px}
30116
31288
  "instruction": instruction,
30117
31289
  "reason": trim(str(route.get("reason", "") or "").strip(), 600),
30118
31290
  "source": trim(str(route.get("source", "") or "").strip(), 40),
31291
+ "progress_fp": self._watchdog_state_fingerprint(board),
30119
31292
  "task_level": int(task_level),
30120
31293
  "execution_mode": execution_mode,
30121
31294
  "task_type": task_type,
@@ -30200,8 +31373,9 @@ body{padding:18px}
30200
31373
  profile["task_type"] = task_type
30201
31374
  if complexity in TASK_COMPLEXITY_LEVELS:
30202
31375
  # Floor protection: if plan mode set a floor, do not allow downgrade
30203
- if self.runtime_complexity_floor == "complex" and complexity == "simple":
30204
- complexity = "complex"
31376
+ _route_complexity_floor = normalize_task_complexity(self.runtime_complexity_floor, default="")
31377
+ if _route_complexity_floor in TASK_COMPLEXITY_LEVELS and task_complexity_rank(complexity) < task_complexity_rank(_route_complexity_floor):
31378
+ complexity = _route_complexity_floor
30205
31379
  profile["complexity"] = complexity
30206
31380
  profile["scale_preference"] = scale_preference if scale_preference in TASK_SCALE_PREFERENCES else "balanced"
30207
31381
  if objective:
@@ -30571,8 +31745,25 @@ body{padding:18px}
30571
31745
  )
30572
31746
  self._emit("status", {"summary": f"reviewer finish blocked: {gate_reason}"})
30573
31747
  else:
31748
+ bb_finish = self._ensure_blackboard()
31749
+ profile_finish = self._ensure_blackboard_task_profile(bb_finish)
31750
+ exec_mode = normalize_execution_mode(
31751
+ profile_finish.get("execution_mode", self._effective_execution_mode()),
31752
+ default=self._effective_execution_mode(),
31753
+ )
30574
31754
  approval_note = summary_arg or output or "finish tool acknowledged"
30575
- self._blackboard_mark_approved(approval_note, role_key)
31755
+ if exec_mode == EXECUTION_MODE_SYNC:
31756
+ self._blackboard_append_section(
31757
+ "execution_logs",
31758
+ role_key,
31759
+ (
31760
+ "finish requested but deferred: sync mode requires reviewer pass before approval.\n"
31761
+ f"summary: {approval_note}"
31762
+ ),
31763
+ )
31764
+ self._emit("status", {"summary": "finish deferred: sync mode requires reviewer approval"})
31765
+ else:
31766
+ self._blackboard_mark_approved(approval_note, role_key)
30576
31767
  if not ok and output:
30577
31768
  self._blackboard_append_section(
30578
31769
  "execution_logs",
@@ -30610,6 +31801,7 @@ body{padding:18px}
30610
31801
  role_key = self._sanitize_agent_role(role)
30611
31802
  status = str((step or {}).get("status", "") or "")
30612
31803
  text = trim(str((step or {}).get("text", "") or "").strip(), BLACKBOARD_MAX_TEXT)
31804
+ tool_results = (step or {}).get("tool_results", []) if isinstance((step or {}).get("tool_results"), list) else []
30613
31805
  if role_key and text:
30614
31806
  board = self._ensure_blackboard()
30615
31807
  board["last_worker_reply"] = {
@@ -30630,7 +31822,28 @@ body{padding:18px}
30630
31822
  self._blackboard_set_status("REVIEWING")
30631
31823
  if self._reviewer_deems_done(text):
30632
31824
  self._blackboard_mark_approved(text, role_key)
30633
- for item in (step or {}).get("tool_results", []) or []:
31825
+ explicit_todo_write = any(
31826
+ isinstance(item, dict) and str(item.get("name", "") or "") in {"TodoWrite", "TodoWriteRescue"}
31827
+ for item in tool_results
31828
+ )
31829
+ if role_key and not explicit_todo_write:
31830
+ source_text = text or self._latest_agent_assistant_text(role_key)
31831
+ if re.search(r"(?m)^\s*(?:[-*•>]+\s*)?\[(?: |>|x)\]\s+\S", source_text or ""):
31832
+ board = self._ensure_blackboard()
31833
+ step_id = trim(str((self._get_active_plan_step(board) or {}).get("id", "") or ""), 20)
31834
+ parsed_rows = extract_todo_rows_from_text(
31835
+ source_text,
31836
+ default_parent_step_id=step_id,
31837
+ limit=12,
31838
+ )
31839
+ if parsed_rows:
31840
+ merged = self._merge_todo_signal_rows(parsed_rows, role=role_key, board=board)
31841
+ if merged != self.todo.no_changes_text():
31842
+ self._emit(
31843
+ "status",
31844
+ {"summary": f"todo synced from canonical {role_key} text"},
31845
+ )
31846
+ for item in tool_results:
30634
31847
  if isinstance(item, dict) and bool(item.get("bb_applied", False)):
30635
31848
  continue
30636
31849
  self._blackboard_update_from_tool_result(role_key, item)
@@ -31307,6 +32520,10 @@ body{padding:18px}
31307
32520
  "TODO TRACKING (mandatory): "
31308
32521
  "When a plan step is active, follow the current todo subtask order instead of inventing a parallel path. "
31309
32522
  "After completing ONE subtask, call TodoWrite immediately — mark that subtask as 'completed' and move the next one to 'in_progress' before doing more work. "
32523
+ "Prefer TodoWrite items as objects with explicit fields: "
32524
+ "{content, status, owner?, parent_step_id?}. "
32525
+ "If you must use strings, use ONLY canonical prefixes: '[ ]', '[>]', '[x]'. "
32526
+ "Do not use emoji markers or free-form localized status labels in TodoWrite payloads. "
31310
32527
  "Do not silently batch multiple subtasks and do not delay todo updates until the end of the step. "
31311
32528
  "This manual update is critical because skill re-evaluation is triggered by actual todo progress. "
31312
32529
  "EDIT METHODOLOGY (follow strictly): "
@@ -31423,35 +32640,9 @@ body{padding:18px}
31423
32640
  content = str(item).strip()
31424
32641
  owner = owner_hint
31425
32642
  parent_step_id = active_step_id
31426
- # Parse status from string prefix markers:
31427
- # "✅ task" / "[x] task" / "[done] task" / "[completed] task" → completed
31428
- # "▶ task" / "[>] task" / "[doing] task" / "[in_progress] task" → in_progress
31429
- # "⬜ task" / "[ ] task" / "[pending] task" / "[todo] task" → pending
31430
- import re as _re_status
31431
- _prefix_m = _re_status.match(
31432
- r'^(?:'
31433
- r'[\u2705\u2611]\s*' # ✅ ☑
31434
- r'|\[x\]\s*|\[done\]\s*|\[completed\]\s*|\[finish(?:ed)?\]\s*'
31435
- r'|\(done\)\s*|\(completed\)\s*|\(x\)\s*'
31436
- r')',
31437
- content, _re_status.IGNORECASE
31438
- )
31439
- _prefix_ip = _re_status.match(
31440
- r'^(?:'
31441
- r'[\u25b6\u25ba\u27a1]\s*' # ▶ ► ➡
31442
- r'|\[>\]\s*|\[doing\]\s*|\[in.?progress\]\s*'
31443
- r'|\(doing\)\s*|\(in.?progress\)\s*'
31444
- r')',
31445
- content, _re_status.IGNORECASE
31446
- )
31447
- if _prefix_m:
31448
- status = "completed"
31449
- content = content[_prefix_m.end():].strip()
31450
- elif _prefix_ip:
31451
- status = "in_progress"
31452
- content = content[_prefix_ip.end():].strip()
31453
- else:
31454
- status = "pending"
32643
+ parsed_status, parsed_content = split_todo_status_text(content)
32644
+ status = parsed_status or "pending"
32645
+ content = parsed_content or content
31455
32646
  content = normalize_work_text(content) or content
31456
32647
  if not content:
31457
32648
  continue
@@ -31478,13 +32669,40 @@ body{padding:18px}
31478
32669
  if i >= in_progress_index:
31479
32670
  r["status"] = "in_progress"
31480
32671
  break
31481
- if active_step is not None:
32672
+ route_kind = self._todo_route_kind(role=owner_hint)
32673
+ if route_kind in {"plan_single", "plan_sync"}:
31482
32674
  return self._merge_plan_worker_todo_items(clean_items, role=owner_hint)
31483
- if self._is_multi_agent_mode() and owner_hint in {"developer", "explorer", "reviewer"}:
32675
+ if route_kind == "pure_sync":
31484
32676
  return self._merge_owner_scoped_todo_items(clean_items, role=owner_hint)
31485
32677
  return self.todo.update(clean_items)
31486
32678
 
31487
- def _analyze_todo_result(self, tool_name: str, output: str) -> tuple[str, str]:
32679
+ def _todo_progress_signature(self, rows: list[dict] | None = None) -> list[tuple[str, str, str, str]]:
32680
+ items = rows if isinstance(rows, list) else self.todo.snapshot()
32681
+ sig: list[tuple[str, str, str, str]] = []
32682
+ for row in items:
32683
+ if not isinstance(row, dict):
32684
+ continue
32685
+ sig.append(
32686
+ (
32687
+ normalize_work_text(str(row.get("content", "") or "")).strip().lower(),
32688
+ str(row.get("status", "pending") or "pending").strip().lower(),
32689
+ str(row.get("owner", "") or "").strip().lower(),
32690
+ str(row.get("parent_step_id", "") or "").strip(),
32691
+ )
32692
+ )
32693
+ return sig
32694
+
32695
+ def _todo_progress_changed(self, before_rows: list[dict] | None, after_rows: list[dict] | None) -> bool:
32696
+ return self._todo_progress_signature(before_rows) != self._todo_progress_signature(after_rows)
32697
+
32698
+ def _analyze_todo_result(
32699
+ self,
32700
+ tool_name: str,
32701
+ output: str,
32702
+ *,
32703
+ before_rows: list[dict] | None = None,
32704
+ after_rows: list[dict] | None = None,
32705
+ ) -> tuple[str, str]:
31488
32706
  txt = str(output or "").strip()
31489
32707
  low = txt.lower()
31490
32708
  has_worker_rows = self._todo_runtime_has_worker_rows()
@@ -31805,6 +33023,36 @@ body{padding:18px}
31805
33023
  if budget_forced
31806
33024
  else ""
31807
33025
  )
33026
+ # If in plan mode, include the current in-progress subtask and the <step-verified/> escape path
33027
+ plan_subtask_hint = ""
33028
+ try:
33029
+ bb = self._ensure_blackboard()
33030
+ todos = bb.get("project_todos", [])
33031
+ active_step = next(
33032
+ (t for t in todos if t.get("category") == "plan_step" and t.get("status") == "in_progress"),
33033
+ None,
33034
+ )
33035
+ if active_step:
33036
+ step_id = str(active_step.get("id", "") or "")
33037
+ active_subtask = next(
33038
+ (
33039
+ t for t in todos
33040
+ if t.get("category") != "plan_step"
33041
+ and t.get("status") == "in_progress"
33042
+ and str(t.get("parent_step_id", "") or "") == step_id
33043
+ ),
33044
+ None,
33045
+ )
33046
+ if active_subtask:
33047
+ subtask_text = trim(str(active_subtask.get("content", "") or ""), 120)
33048
+ plan_subtask_hint = (
33049
+ f"\n当前子任务: {subtask_text}\n"
33050
+ "如果此子任务需要视觉/浏览器验证而无法通过 bash 完整执行,"
33051
+ "请创建相关文件,通过代码审查确认实现正确,"
33052
+ "然后在回复中发出 <step-verified/> 并调用 TodoWrite 将子任务标记为 completed。"
33053
+ )
33054
+ except Exception:
33055
+ pass
31808
33056
  self.messages.append(
31809
33057
  {
31810
33058
  "role": "user",
@@ -31813,6 +33061,7 @@ body{padding:18px}
31813
33061
  f"系统检测到空动作回合(consecutive_empty_action={int(streak)})。"
31814
33062
  "你刚才进行了深入思考,但没有输出任何最终结果或工具调用。"
31815
33063
  f"{budget_note}"
33064
+ f"{plan_subtask_hint}"
31816
33065
  "请结束思考,立即基于现有推导输出最终结论,或发起一个明确、可执行的工具调用。 "
31817
33066
  "System notice: you returned thinking-only content without final answer or tool calls. "
31818
33067
  "Stop internal reasoning now and immediately output either the final conclusion or exactly one "
@@ -31948,12 +33197,15 @@ body{padding:18px}
31948
33197
  except Exception:
31949
33198
  token_decoded = token
31950
33199
  token_decoded = token_decoded.strip()
31951
- if token_decoded and token_decoded not in out:
31952
- out.append(token_decoded)
33200
+ for piece in split_structured_todo_content(token_decoded, limit=7):
33201
+ piece_text = str(piece or "").strip()
33202
+ if piece_text and piece_text not in out:
33203
+ out.append(piece_text)
31953
33204
  if out:
31954
33205
  return out[:7]
31955
33206
  # Fallback: parse non-empty lines / bullets
31956
- for line in text.splitlines():
33207
+ normalized_text = normalize_embedded_newlines(text)
33208
+ for line in normalized_text.splitlines():
31957
33209
  s = line.strip().strip(",")
31958
33210
  s = re.sub(r"^[\-\*\d\.\)\s]+", "", s).strip()
31959
33211
  if not s:
@@ -32311,20 +33563,16 @@ body{padding:18px}
32311
33563
  )
32312
33564
  return out
32313
33565
  if name == "TodoWrite":
32314
- # Protect plan_step todos: worker TodoWrite creates sub-items, not replacements
32315
33566
  bb = self._ensure_blackboard()
32316
- has_plan_steps = any(
32317
- t.get("category") == "plan_step"
32318
- for t in bb.get("project_todos", [])
32319
- )
32320
- if has_plan_steps:
33567
+ route_kind = self._todo_route_kind(role=str(role_key or ""), board=bb)
33568
+ if route_kind in {"plan_single", "plan_sync"}:
32321
33569
  items = args.get("items", [])
32322
33570
  if isinstance(items, list):
32323
33571
  for item in items:
32324
33572
  if isinstance(item, dict) and not item.get("key", "").startswith("bb:"):
32325
33573
  item["owner"] = str(role_key or "developer")
32326
33574
  result = self._merge_plan_worker_todo_items(items, role=str(role_key or "developer"))
32327
- elif self._is_multi_agent_mode() and role_key in {"developer", "explorer", "reviewer"}:
33575
+ elif route_kind == "pure_sync":
32328
33576
  items = args.get("items", [])
32329
33577
  if isinstance(items, list):
32330
33578
  for item in items:
@@ -32333,50 +33581,6 @@ body{padding:18px}
32333
33581
  result = self._merge_owner_scoped_todo_items(items, role=str(role_key))
32334
33582
  else:
32335
33583
  result = self.todo.update(args["items"])
32336
- # Fix 1: Auto-advance plan step when all subtasks are completed
32337
- # This handles the case where the worker's last turn only calls TodoWrite
32338
- # and _post_execution_plan_step_check would miss it due to no "real" tool evidence
32339
- if has_plan_steps:
32340
- try:
32341
- _as = self._get_active_plan_step()
32342
- if isinstance(_as, dict):
32343
- _as_id = str(_as.get("id", "") or "")
32344
- if _as_id and self._step_subtasks_all_completed(_as):
32345
- _acc_ev = self._collect_accumulated_step_evidence(_as)
32346
- if _acc_ev and _acc_ev != "accumulated-step-evidence":
32347
- # Has real evidence — auto-advance
32348
- self._advance_plan_step(
32349
- evidence=_acc_ev or "subtasks-all-completed",
32350
- actor=str(role_key or "developer"),
32351
- )
32352
- elif self._step_has_accumulated_evidence(_as, bb):
32353
- self._advance_plan_step(
32354
- evidence="subtasks-all-completed",
32355
- actor=str(role_key or "developer"),
32356
- )
32357
- except Exception:
32358
- pass
32359
- # Fix 5b: TodoWrite loop detection — force-advance after 3 consecutive calls
32360
- if has_plan_steps:
32361
- try:
32362
- _as5 = self._get_active_plan_step()
32363
- if isinstance(_as5, dict):
32364
- _as5_id = str(_as5.get("id", "") or "")
32365
- if _as5_id:
32366
- if not hasattr(self, '_todowrite_step_counter'):
32367
- self._todowrite_step_counter = {}
32368
- cnt = self._todowrite_step_counter.get(_as5_id, 0) + 1
32369
- self._todowrite_step_counter[_as5_id] = cnt
32370
- if (cnt >= 3
32371
- and self._step_subtasks_all_completed(_as5)
32372
- and self._step_has_accumulated_evidence(_as5, bb)):
32373
- # Force advance — worker is stuck in a loop AND step has real evidence
32374
- self._advance_plan_step(
32375
- evidence="force-advance:todowrite-loop-detected",
32376
- actor=str(role_key or "developer"),
32377
- )
32378
- except Exception:
32379
- pass
32380
33584
  # Step completion skill recheck: if any item just got marked completed, re-evaluate skills
32381
33585
  # This fires in ALL modes (single/sync/plan) when developer writes todos
32382
33586
  try:
@@ -33195,6 +34399,18 @@ body{padding:18px}
33195
34399
  },
33196
34400
  )
33197
34401
  self._persist()
34402
+ _proc = getattr(self, "_running_bash_proc", None)
34403
+ if _proc is not None:
34404
+ try:
34405
+ if os.name == "posix":
34406
+ try:
34407
+ os.killpg(os.getpgid(_proc.pid), signal.SIGKILL)
34408
+ except Exception:
34409
+ _proc.kill()
34410
+ else:
34411
+ _proc.kill()
34412
+ except Exception:
34413
+ pass
33198
34414
 
33199
34415
  def _reviewer_approval_log_gate(self, board: dict | None = None) -> tuple[bool, str]:
33200
34416
  bb = board if isinstance(board, dict) else self._ensure_blackboard()
@@ -33599,8 +34815,8 @@ body{padding:18px}
33599
34815
  isinstance(t, dict) and t.get("category") == "plan_step"
33600
34816
  for t in board.get("project_todos", [])
33601
34817
  )
33602
- _sync_complexity = str(profile.get("complexity", "simple") or "simple")
33603
- if not _sync_has_plan and _sync_complexity in ("moderate", "complex", "expert"):
34818
+ _sync_complexity = normalize_task_complexity(profile.get("complexity", "simple"), default="simple")
34819
+ if not _sync_has_plan and task_complexity_at_least(_sync_complexity, "moderate"):
33604
34820
  self.messages.append({
33605
34821
  "role": "system",
33606
34822
  "content": (
@@ -33707,29 +34923,59 @@ body{padding:18px}
33707
34923
  self._mark_all_done_silently(note)
33708
34924
  self._emit("status", {"summary": "manager decided finish; run paused"})
33709
34925
  break
33710
- # Detect manager stuck: same instruction repeated N times → force advance + break
34926
+ # Detect manager loop: same instruction repeated with unchanged progress.
33711
34927
  import hashlib as _hl_mgr
33712
- _cur_hash = _hl_mgr.sha1((target + "|" + instruction).encode("utf-8")).hexdigest()[:12]
34928
+ _delegate_progress_fp = self._watchdog_state_fingerprint(self._ensure_blackboard())
34929
+ _cur_hash = _hl_mgr.sha1((target + "|" + instruction + "|" + _delegate_progress_fp).encode("utf-8")).hexdigest()[:12]
33713
34930
  if _cur_hash == _prev_delegation_hash:
33714
34931
  _repeat_delegation_count += 1
33715
34932
  else:
33716
34933
  _repeat_delegation_count = 0
33717
34934
  _prev_delegation_hash = _cur_hash
33718
34935
  if _repeat_delegation_count >= 3:
33719
- self._emit("status", {"summary": f"manager stuck: repeated identical delegation x{_repeat_delegation_count + 1}; forcing advance"})
33720
34936
  _bb_stuck = self._ensure_blackboard()
33721
34937
  _stuck_step = next(
33722
34938
  (t for t in _bb_stuck.get("project_todos", [])
33723
34939
  if t.get("category") == "plan_step" and t.get("status") == "in_progress"),
33724
34940
  None,
33725
34941
  )
33726
- if _stuck_step:
33727
- self._advance_plan_step(evidence="manager stuck: repeated delegation", actor="manager")
33728
- else:
33729
- self._blackboard_mark_approved("manager stuck loop break", "manager")
33730
- self._mark_all_done_silently("manager stuck: repeated delegation break")
33731
- break
34942
+ _step_note = trim(str((_stuck_step or {}).get("content", "") or ""), 200)
34943
+ route = self._manager_recovery_route_for_repeated_delegate(route, board=_bb_stuck)
34944
+ target = str(route.get("target", "") or "").strip().lower()
34945
+ instruction = trim(str(route.get("instruction", "") or "").strip(), 1400)
33732
34946
  _repeat_delegation_count = 0
34947
+ _prev_delegation_hash = ""
34948
+ self._emit(
34949
+ "status",
34950
+ {
34951
+ "summary": (
34952
+ f"manager loop recovery: repeated identical delegation under unchanged progress; "
34953
+ f"rerouting to {target}"
34954
+ )
34955
+ },
34956
+ )
34957
+ self._append_manager_context(
34958
+ {
34959
+ "role": "system",
34960
+ "content": (
34961
+ "[manager-loop-guard] Repeated identical delegation detected under unchanged progress. "
34962
+ "Do NOT mark the active step completed just because the owner was delegated repeatedly. "
34963
+ "Use a recovery route based on current step evidence and worker todo state."
34964
+ + (f" Active step: {_step_note}." if _step_note else "")
34965
+ + (f" Recovery target: {target}." if target else "")
34966
+ ),
34967
+ "ts": now_ts(),
34968
+ }
34969
+ )
34970
+ self._blackboard_append_section(
34971
+ "execution_logs",
34972
+ "manager",
34973
+ (
34974
+ "manager repeated identical delegation; applied recovery reroute"
34975
+ + (f"\nactive_step: {_step_note}" if _step_note else "")
34976
+ + (f"\nrecovery_target: {target}" if target else "")
34977
+ ),
34978
+ )
33733
34979
  role = self._sanitize_agent_role(target) or "developer"
33734
34980
  self._inject_manager_instruction(
33735
34981
  role,
@@ -33758,6 +35004,24 @@ body{padding:18px}
33758
35004
  self._blackboard_update_from_worker_step(role, step)
33759
35005
  # Post-execution plan step advancement (replaces pre-execution advancement)
33760
35006
  self._post_execution_plan_step_check(route, step if isinstance(step, dict) else {})
35007
+ progress_capsule = self._manager_worker_progress_capsule(
35008
+ role,
35009
+ step if isinstance(step, dict) else {},
35010
+ self._ensure_blackboard(),
35011
+ )
35012
+ if progress_capsule:
35013
+ recent_mgr = self.manager_context[-4:] if isinstance(self.manager_context, list) else []
35014
+ if not any(
35015
+ isinstance(msg, dict) and str(msg.get("content", "") or "").strip() == progress_capsule
35016
+ for msg in recent_mgr
35017
+ ):
35018
+ self._append_manager_context(
35019
+ {
35020
+ "role": "system",
35021
+ "content": progress_capsule,
35022
+ "ts": now_ts(),
35023
+ }
35024
+ )
33761
35025
  # Fix 6b: Pure sync no-plan — read worker-done signal and notify manager
33762
35026
  _bb_sync = self._ensure_blackboard()
33763
35027
  if _bb_sync.pop("sync_worker_round_done", False):
@@ -34106,17 +35370,19 @@ body{padding:18px}
34106
35370
  bb["plan"]["phase"] = "synthesis"
34107
35371
  self.blackboard = bb
34108
35372
 
34109
- # Synthesis with retry (up to 2 attempts) + minimal fallback
35373
+ # Synthesis with retry + model fallback + deterministic fallback
34110
35374
  proposal = None
34111
- for _synth_attempt in range(2):
35375
+ for _synth_attempt in range(PLAN_MODE_SYNTHESIS_MAX_ATTEMPTS):
34112
35376
  proposal = self._plan_mode_synthesize_proposal(pinned_selection)
34113
35377
  if proposal and proposal.get("options"):
34114
35378
  break
34115
- if _synth_attempt == 0:
35379
+ if _synth_attempt < (PLAN_MODE_SYNTHESIS_MAX_ATTEMPTS - 1):
34116
35380
  self._emit("status", {"summary": "plan-mode: synthesis retry"})
34117
35381
  if not proposal or not proposal.get("options"):
34118
35382
  # Last resort: minimal fallback with simpler prompt and higher token budget
34119
35383
  proposal = self._synthesis_minimal_fallback(pinned_selection)
35384
+ if not proposal or not proposal.get("options"):
35385
+ proposal = self._synthesis_programmatic_fallback()
34120
35386
  if not proposal or not proposal.get("options"):
34121
35387
  self._emit("status", {"summary": "plan-mode: synthesis failed, falling back to direct execution"})
34122
35388
  self.runtime_plan_mode_needed = False
@@ -34522,21 +35788,29 @@ body{padding:18px}
34522
35788
  f"- Option A: Direct workaround — bypass the blocker with an alternative method\n"
34523
35789
  f"- Option B: Different path — re-approach the goal from a completely different angle\n"
34524
35790
  f"- Option C: Minimal viable + user action items — do what's possible now, list what the user needs to do manually\n\n"
34525
- f"Call the submit_plan_proposal tool with:\n"
35791
+ f"You MUST call the submit_plan_proposal tool exactly once with:\n"
34526
35792
  f"- context: brief failure analysis (what was tried, what failed, why)\n"
34527
35793
  f"- options: array of 3 options, each with id (A/B/C), title, summary, steps, pros, cons, risk\n"
34528
- f"- recommended: id of the recommended option\n\n"
35794
+ f"- recommended: id of the recommended option\n"
35795
+ f"Do NOT answer with prose-only markdown. A response without submit_plan_proposal tool call is invalid.\n\n"
34529
35796
  f"{model_language_instruction(self.ui_language)}"
34530
35797
  )
34531
35798
  synthesis_ctx = [
34532
- {"role": "system", "content": "You are a recovery planner analyzing execution failures and proposing alternative approaches.", "ts": now_ts()},
35799
+ {
35800
+ "role": "system",
35801
+ "content": (
35802
+ "You are a recovery planner analyzing execution failures and proposing alternative approaches. "
35803
+ "You MUST call submit_plan_proposal exactly once."
35804
+ ),
35805
+ "ts": now_ts(),
35806
+ },
34533
35807
  {"role": "user", "content": synthesis_prompt, "ts": now_ts()},
34534
35808
  ]
34535
35809
  try:
34536
35810
  response = self._chat_with_same_model_retry(
34537
35811
  synthesis_ctx,
34538
35812
  tools=self._plan_mode_synthesis_tools(),
34539
- system="Generate a structured stall recovery plan. Use the submit_plan_proposal tool.",
35813
+ system="Generate a structured stall recovery plan. You MUST call submit_plan_proposal exactly once. Do not answer with plain text.",
34540
35814
  max_tokens=STALL_PLAN_SYNTHESIS_MAX_TOKENS,
34541
35815
  think=False,
34542
35816
  stream_thinking=False,
@@ -34545,12 +35819,33 @@ body{padding:18px}
34545
35819
  context_label="stall-plan synthesis",
34546
35820
  retries=MODEL_OUTPUT_RETRY_TIMES,
34547
35821
  )
34548
- tool_calls = response.get("tool_calls", [])
34549
- for tc in tool_calls:
34550
- if tc.get("function", {}).get("name") == "submit_plan_proposal":
34551
- args = tc["function"].get("arguments", {})
34552
- if isinstance(args, dict) and args.get("options"):
34553
- return dict(args)
35822
+ proposal = self._extract_plan_proposal_from_response(response)
35823
+ if proposal.get("options"):
35824
+ return proposal
35825
+ repair_response = self._chat_with_same_model_retry(
35826
+ synthesis_ctx + [
35827
+ {
35828
+ "role": "user",
35829
+ "content": (
35830
+ "Previous answer was invalid because it did not produce a valid submit_plan_proposal tool call. "
35831
+ "Retry now. Output exactly one submit_plan_proposal tool call and no prose."
35832
+ ),
35833
+ "ts": now_ts(),
35834
+ }
35835
+ ],
35836
+ tools=self._plan_mode_synthesis_tools(),
35837
+ system="You MUST call submit_plan_proposal exactly once. Do not answer with plain text.",
35838
+ max_tokens=STALL_PLAN_SYNTHESIS_MAX_TOKENS,
35839
+ think=False,
35840
+ stream_thinking=False,
35841
+ on_thinking_chunk=self._append_live_thinking,
35842
+ pinned_selection=pinned_selection,
35843
+ context_label="stall-plan synthesis repair",
35844
+ retries=1,
35845
+ )
35846
+ proposal = self._extract_plan_proposal_from_response(repair_response)
35847
+ if proposal.get("options"):
35848
+ return proposal
34554
35849
  except Exception as exc:
34555
35850
  self._emit("status", {"summary": f"stall plan synthesis error: {exc}"})
34556
35851
  return {}
@@ -34621,6 +35916,186 @@ body{padding:18px}
34621
35916
  lines.append(f"- {trim(str(t), 100)}")
34622
35917
  return "\n".join(lines)
34623
35918
 
35919
+ def _normalize_plan_proposal_option(self, raw: dict, *, fallback_id: str) -> dict | None:
35920
+ if not isinstance(raw, dict):
35921
+ return None
35922
+ opt_id = trim(str(raw.get("id", "") or fallback_id).strip().upper(), 8) or fallback_id
35923
+ title = trim(str(raw.get("title", "") or "").strip(), 200)
35924
+ summary = trim(str(raw.get("summary", "") or "").strip(), 600)
35925
+ steps_raw = raw.get("steps", [])
35926
+ steps: list[str] = []
35927
+ if isinstance(steps_raw, list):
35928
+ for item in steps_raw:
35929
+ text = normalize_embedded_newlines(str(item or "")).strip()
35930
+ if text:
35931
+ steps.append(trim(text, PLAN_STEP_FULL_CONTENT_MAX_CHARS))
35932
+ elif isinstance(steps_raw, str):
35933
+ text = normalize_embedded_newlines(steps_raw).strip()
35934
+ if text:
35935
+ steps.append(trim(text, PLAN_STEP_FULL_CONTENT_MAX_CHARS))
35936
+ pros = trim(str(raw.get("pros", "") or "").strip(), 400)
35937
+ cons = trim(str(raw.get("cons", "") or "").strip(), 400)
35938
+ risk = trim(str(raw.get("risk", "") or "").strip().lower(), 20)
35939
+ if risk not in {"low", "medium", "high"}:
35940
+ risk = "medium"
35941
+ if not title and summary:
35942
+ title = trim(summary.split("\n", 1)[0], 120)
35943
+ if not title and steps:
35944
+ title = trim(steps[0].split("\n", 1)[0], 120)
35945
+ if not summary and steps:
35946
+ summary = trim(steps[0], 300)
35947
+ if not steps:
35948
+ return None
35949
+ return {
35950
+ "id": opt_id,
35951
+ "title": title or f"Option {opt_id}",
35952
+ "summary": summary or title or f"Plan {opt_id}",
35953
+ "steps": steps,
35954
+ "pros": pros,
35955
+ "cons": cons,
35956
+ "risk": risk,
35957
+ }
35958
+
35959
+ def _normalize_plan_proposal_payload(self, raw: object) -> dict:
35960
+ src = raw if isinstance(raw, dict) else {}
35961
+ context = trim(str(src.get("context", "") or "").strip(), 2000)
35962
+ raw_options = src.get("options", [])
35963
+ if isinstance(raw_options, dict):
35964
+ raw_options = [raw_options]
35965
+ if not isinstance(raw_options, list):
35966
+ raw_options = []
35967
+ option_ids = ("A", "B", "C")
35968
+ options: list[dict] = []
35969
+ seen_ids: set[str] = set()
35970
+ for idx, item in enumerate(raw_options[: max(1, PLAN_MODE_MAX_OPTIONS * 2)]):
35971
+ normalized = self._normalize_plan_proposal_option(
35972
+ item,
35973
+ fallback_id=option_ids[min(idx, len(option_ids) - 1)],
35974
+ )
35975
+ if not normalized:
35976
+ continue
35977
+ opt_id = str(normalized.get("id", "") or "").strip().upper() or option_ids[min(idx, len(option_ids) - 1)]
35978
+ if opt_id in seen_ids:
35979
+ opt_id = option_ids[min(len(seen_ids), len(option_ids) - 1)]
35980
+ normalized["id"] = opt_id
35981
+ if opt_id in seen_ids:
35982
+ continue
35983
+ seen_ids.add(opt_id)
35984
+ options.append(normalized)
35985
+ if len(options) >= PLAN_MODE_MAX_OPTIONS:
35986
+ break
35987
+ recommended = trim(str(src.get("recommended", "") or "").strip().upper(), 8)
35988
+ valid_ids = {str(opt.get("id", "") or "").strip().upper() for opt in options}
35989
+ if recommended not in valid_ids:
35990
+ recommended = str(options[0].get("id", "A") or "A") if options else ""
35991
+ return {
35992
+ "context": context,
35993
+ "options": options,
35994
+ "recommended": recommended,
35995
+ }
35996
+
35997
+ def _parse_plan_proposal_from_text(self, text: str) -> dict:
35998
+ raw = str(text or "").strip()
35999
+ if not raw:
36000
+ return {}
36001
+ candidates: list[str] = [raw]
36002
+ fence_matches = re.findall(r"```(?:json)?\s*([\s\S]*?)```", raw, flags=re.IGNORECASE)
36003
+ for block in fence_matches:
36004
+ block_text = str(block or "").strip()
36005
+ if block_text:
36006
+ candidates.append(block_text)
36007
+ start = raw.find("{")
36008
+ end = raw.rfind("}")
36009
+ if start >= 0 and end > start:
36010
+ candidates.append(raw[start : end + 1].strip())
36011
+ for candidate in candidates:
36012
+ repaired = repair_truncated_json_object(candidate)
36013
+ for probe in [candidate, repaired]:
36014
+ if not probe:
36015
+ continue
36016
+ try:
36017
+ parsed = json.loads(probe)
36018
+ except Exception:
36019
+ continue
36020
+ if isinstance(parsed, list):
36021
+ parsed = {"context": "", "options": parsed, "recommended": ""}
36022
+ proposal = self._normalize_plan_proposal_payload(parsed)
36023
+ if proposal.get("options"):
36024
+ return proposal
36025
+ return {}
36026
+
36027
+ def _extract_plan_proposal_from_response(self, response: dict | None) -> dict:
36028
+ if not isinstance(response, dict):
36029
+ return {}
36030
+ tool_calls = response.get("tool_calls", [])
36031
+ if isinstance(tool_calls, list):
36032
+ for tc in tool_calls:
36033
+ if not isinstance(tc, dict):
36034
+ continue
36035
+ fn = tc.get("function", {}) if isinstance(tc.get("function"), dict) else {}
36036
+ if str(fn.get("name", "") or "").strip() != "submit_plan_proposal":
36037
+ continue
36038
+ args = fn.get("arguments", {})
36039
+ if isinstance(args, dict):
36040
+ proposal = self._normalize_plan_proposal_payload(args)
36041
+ if proposal.get("options"):
36042
+ return proposal
36043
+ elif isinstance(args, str):
36044
+ parsed, _ = parse_tool_arguments_with_error(args)
36045
+ proposal = self._normalize_plan_proposal_payload(parsed)
36046
+ if proposal.get("options"):
36047
+ return proposal
36048
+ return self._parse_plan_proposal_from_text(str(response.get("content", "") or ""))
36049
+
36050
+ def _synthesis_programmatic_fallback(self) -> dict:
36051
+ bb = self._ensure_blackboard()
36052
+ goal = trim(str(self.runtime_reclassify_goal or self._latest_user_goal_text() or ""), 1200)
36053
+ findings = bb.get("plan", {}).get("findings", []) if isinstance(bb.get("plan"), dict) else []
36054
+ finding_lines: list[str] = []
36055
+ for row in findings[:6]:
36056
+ if not isinstance(row, dict):
36057
+ continue
36058
+ content = trim(str(row.get("content", "") or "").strip(), 280)
36059
+ if content:
36060
+ finding_lines.append(content)
36061
+ context = trim(
36062
+ (
36063
+ "Fallback synthesis generated automatically from the user goal and current research findings. "
36064
+ + (" | ".join(finding_lines[:3]) if finding_lines else goal)
36065
+ ),
36066
+ 1800,
36067
+ )
36068
+ detailed_steps = [
36069
+ "1. Scope and constraints\nClarify the exact deliverable, inputs, and acceptance criteria for this task.",
36070
+ "2. Core implementation\nBuild the main artifact for the request using the most direct workable path.",
36071
+ "3. Verification\nRun at least one observable validation and capture the result.",
36072
+ "4. Delivery report\nSummarize what was built, how to run it, and the key outputs.",
36073
+ ]
36074
+ if finding_lines:
36075
+ detailed_steps = [
36076
+ "1. Review findings and lock scope\nUse the collected findings to define the exact execution boundary and required inputs.",
36077
+ "2. Prepare files and dependencies\nCreate or align the necessary files, folders, and runtime prerequisites for the task.",
36078
+ "3. Implement the main work\nExecute the core build/change/generation work for the requested output.",
36079
+ "4. Validate with observable evidence\nRun a concrete check and confirm the expected output, exit code, or rendered result.",
36080
+ "5. Generate delivery report\nSummarize what was built, how to run it, and the key outputs.",
36081
+ ]
36082
+ proposal = {
36083
+ "context": context,
36084
+ "options": [
36085
+ {
36086
+ "id": "A",
36087
+ "title": "Direct Execution Plan",
36088
+ "summary": trim(goal or "Execute the requested task with a direct, verifiable plan.", 240),
36089
+ "steps": detailed_steps,
36090
+ "pros": "Deterministic fallback that keeps plan-mode available even when model synthesis formatting is unstable.",
36091
+ "cons": "Less tailored than a fully synthesized multi-option proposal.",
36092
+ "risk": "medium",
36093
+ }
36094
+ ],
36095
+ "recommended": "A",
36096
+ }
36097
+ return self._normalize_plan_proposal_payload(proposal)
36098
+
34624
36099
  def _plan_mode_synthesize_proposal(self, pinned_selection: str) -> dict:
34625
36100
  bb = self._ensure_blackboard()
34626
36101
  plan_data = bb.get("plan", {})
@@ -34656,10 +36131,11 @@ body{padding:18px}
34656
36131
  f"## Research Findings\n{trim(findings_text, 6000)}\n\n"
34657
36132
  f"{skills_section}"
34658
36133
  f"## Instructions\n"
34659
- f"Call the submit_plan_proposal tool with:\n"
36134
+ f"You MUST call the submit_plan_proposal tool exactly once with:\n"
34660
36135
  f"- context: brief background analysis\n"
34661
36136
  f"- options: array of 1-{PLAN_MODE_MAX_OPTIONS} options, each with id (A/B/C), title, summary, steps, pros, cons, risk\n"
34662
- f"- recommended: id of the recommended option\n\n"
36137
+ f"- recommended: id of the recommended option\n"
36138
+ f"Do NOT answer with prose-only markdown. A response without submit_plan_proposal tool call is invalid.\n\n"
34663
36139
  f"STEP QUALITY REQUIREMENTS:\n"
34664
36140
  f"- Each step must be a concrete, actionable instruction (NOT vague like 'analyze reports')\n"
34665
36141
  f"- Include specific file paths (e.g., 'Read uploaded/IEDM_.parsed.md to extract key findings')\n"
@@ -34731,7 +36207,11 @@ body{padding:18px}
34731
36207
  response = self._chat_with_same_model_retry(
34732
36208
  synthesis_ctx,
34733
36209
  tools=self._plan_mode_synthesis_tools(),
34734
- system="Generate a structured plan proposal. Use the submit_plan_proposal tool.",
36210
+ system=(
36211
+ "Generate a structured plan proposal. "
36212
+ "You MUST call submit_plan_proposal exactly once. "
36213
+ "Do not answer with plain text."
36214
+ ),
34735
36215
  max_tokens=PLAN_MODE_MANAGER_SYNTHESIS_MAX_TOKENS,
34736
36216
  think=False,
34737
36217
  stream_thinking=False,
@@ -34740,13 +36220,31 @@ body{padding:18px}
34740
36220
  context_label="plan-mode synthesis",
34741
36221
  retries=MODEL_OUTPUT_RETRY_TIMES,
34742
36222
  )
34743
- tool_calls = response.get("tool_calls", [])
34744
- for tc in tool_calls:
34745
- if tc.get("function", {}).get("name") == "submit_plan_proposal":
34746
- args = tc["function"].get("arguments", {})
34747
- if isinstance(args, dict) and args.get("options"):
34748
- return dict(args)
34749
- return {}
36223
+ proposal = self._extract_plan_proposal_from_response(response)
36224
+ if proposal.get("options"):
36225
+ return proposal
36226
+ repair_response = self._chat_with_same_model_retry(
36227
+ synthesis_ctx + [
36228
+ {
36229
+ "role": "user",
36230
+ "content": (
36231
+ "Previous answer was invalid because it did not produce a valid submit_plan_proposal tool call. "
36232
+ "Retry now. Output exactly one submit_plan_proposal tool call and no prose."
36233
+ ),
36234
+ "ts": now_ts(),
36235
+ }
36236
+ ],
36237
+ tools=self._plan_mode_synthesis_tools(),
36238
+ system="You MUST call submit_plan_proposal exactly once. Do not answer with plain text.",
36239
+ max_tokens=PLAN_MODE_MANAGER_SYNTHESIS_MAX_TOKENS,
36240
+ think=False,
36241
+ stream_thinking=False,
36242
+ on_thinking_chunk=self._append_live_thinking,
36243
+ pinned_selection=pinned_selection,
36244
+ context_label="plan-mode synthesis repair",
36245
+ retries=1,
36246
+ )
36247
+ return self._extract_plan_proposal_from_response(repair_response)
34750
36248
 
34751
36249
  def _synthesis_minimal_fallback(self, pinned_selection: str) -> dict:
34752
36250
  """Last-resort: ask model for a single simple plan with higher max_tokens."""
@@ -34758,33 +36256,63 @@ body{padding:18px}
34758
36256
  for f in (findings[:5] if isinstance(findings, list) else [])
34759
36257
  )
34760
36258
  prompt = (
34761
- f"Generate ONE simple plan for this task. Call submit_plan_proposal with exactly 1 option.\n\n"
36259
+ f"Generate ONE simple plan for this task. You MUST call submit_plan_proposal with exactly 1 option.\n\n"
34762
36260
  f"Task: {goal}\n\nFindings: {trim(findings_text, 3000)}\n\n"
34763
36261
  f"Return a single option with id='A', title, summary, and 5-10 concrete steps.\n"
36262
+ f"Do NOT answer with prose-only markdown. A response without submit_plan_proposal tool call is invalid.\n"
34764
36263
  f"{model_language_instruction(self.ui_language)}"
34765
36264
  )
34766
36265
  ctx = [
34767
- {"role": "system", "content": "You must call submit_plan_proposal tool.", "ts": now_ts()},
36266
+ {
36267
+ "role": "system",
36268
+ "content": (
36269
+ "You MUST call submit_plan_proposal exactly once. "
36270
+ "Do not answer with plain text."
36271
+ ),
36272
+ "ts": now_ts(),
36273
+ },
34768
36274
  {"role": "user", "content": prompt, "ts": now_ts()},
34769
36275
  ]
34770
36276
  try:
34771
36277
  response = self._chat_with_same_model_retry(
34772
36278
  ctx,
34773
36279
  tools=self._plan_mode_synthesis_tools(),
34774
- system="Call submit_plan_proposal now.",
36280
+ system="You MUST call submit_plan_proposal exactly once. Do not answer with plain text.",
34775
36281
  max_tokens=6000,
34776
36282
  think=False,
34777
36283
  stream_thinking=False,
34778
36284
  on_thinking_chunk=self._append_live_thinking,
34779
36285
  pinned_selection=pinned_selection,
34780
36286
  context_label="plan-mode minimal fallback",
34781
- retries=2,
36287
+ retries=3,
36288
+ )
36289
+ proposal = self._extract_plan_proposal_from_response(response)
36290
+ if proposal.get("options"):
36291
+ return proposal
36292
+ repair_response = self._chat_with_same_model_retry(
36293
+ ctx + [
36294
+ {
36295
+ "role": "user",
36296
+ "content": (
36297
+ "Previous answer was invalid because it did not produce a valid submit_plan_proposal tool call. "
36298
+ "Retry now. Output exactly one submit_plan_proposal tool call and no prose."
36299
+ ),
36300
+ "ts": now_ts(),
36301
+ }
36302
+ ],
36303
+ tools=self._plan_mode_synthesis_tools(),
36304
+ system="You MUST call submit_plan_proposal exactly once. Do not answer with plain text.",
36305
+ max_tokens=6000,
36306
+ think=False,
36307
+ stream_thinking=False,
36308
+ on_thinking_chunk=self._append_live_thinking,
36309
+ pinned_selection=pinned_selection,
36310
+ context_label="plan-mode minimal fallback repair",
36311
+ retries=1,
34782
36312
  )
34783
- for tc in response.get("tool_calls", []):
34784
- if tc.get("function", {}).get("name") == "submit_plan_proposal":
34785
- args = tc["function"].get("arguments", {})
34786
- if isinstance(args, dict) and args.get("options"):
34787
- return dict(args)
36313
+ proposal = self._extract_plan_proposal_from_response(repair_response)
36314
+ if proposal.get("options"):
36315
+ return proposal
34788
36316
  except Exception:
34789
36317
  pass
34790
36318
  return {}
@@ -34868,7 +36396,7 @@ body{padding:18px}
34868
36396
  grouped_steps = self._group_plan_steps(raw_steps if isinstance(raw_steps, list) else [])
34869
36397
  plan_todos: list[dict] = []
34870
36398
  for i, step in enumerate(grouped_steps[:max(1, int(limit))]):
34871
- step_text = trim(str(step or "").strip(), PLAN_STEP_FULL_CONTENT_MAX_CHARS)
36399
+ step_text = trim(normalize_embedded_newlines(step).strip(), PLAN_STEP_FULL_CONTENT_MAX_CHARS)
34872
36400
  if not step_text:
34873
36401
  continue
34874
36402
  step_lines = step_text.split("\n")
@@ -34995,7 +36523,7 @@ body{padding:18px}
34995
36523
  _mid_re_exec = _re_exec.compile(r"(?<=\S)\s+(\d+\.\d+\s)")
34996
36524
  for t in plan_todos:
34997
36525
  idx = int(t.get("plan_step_index", 0) or 0) + 1
34998
- full = str(t.get("full_content", "") or t.get("content", "")).strip()
36526
+ full = normalize_embedded_newlines(t.get("full_content", "") or t.get("content", "")).strip()
34999
36527
  # Normalize: split concatenated N.N sub-steps onto own lines
35000
36528
  full = _mid_re_exec.sub(r"\n\1", full)
35001
36529
  header = full.split("\n")[0] if "\n" in full else full
@@ -35157,7 +36685,7 @@ body{padding:18px}
35157
36685
  # Phase 0: Normalize — split mid-string N.N onto own lines
35158
36686
  normalized: list[str] = []
35159
36687
  for s in raw_steps:
35160
- text = str(s or "").strip()
36688
+ text = normalize_embedded_newlines(s).strip()
35161
36689
  if not text:
35162
36690
  continue
35163
36691
  fixed = mid_numbered_re.sub(r"\n\1", text)
@@ -35492,18 +37020,16 @@ body{padding:18px}
35492
37020
  chosen_title = trim(str(chosen.get("title", "") or choice_id).strip(), 800)
35493
37021
  chosen_summary = trim(str(chosen.get("summary", "") or "").strip(), PLAN_STEP_FULL_CONTENT_MAX_CHARS)
35494
37022
  # Preserve current complexity unless the user explicitly changes it elsewhere.
35495
- _current_complexity = trim(
35496
- str(
35497
- self.runtime_task_complexity
35498
- or profile.get("complexity", judgement.get("complexity", ""))
35499
- or ""
35500
- ).strip().lower(),
35501
- 20,
37023
+ _current_complexity = normalize_task_complexity(
37024
+ self.runtime_task_complexity
37025
+ or profile.get("complexity", judgement.get("complexity", ""))
37026
+ or "",
37027
+ default="",
35502
37028
  )
35503
37029
  if _current_complexity in TASK_COMPLEXITY_LEVELS:
35504
37030
  self.runtime_task_complexity = _current_complexity
35505
37031
  else:
35506
- _current_complexity = trim(str(self.runtime_task_complexity or "").strip().lower(), 20)
37032
+ _current_complexity = normalize_task_complexity(str(self.runtime_task_complexity or "").strip().lower(), default="")
35507
37033
  self.runtime_complexity_floor = str(_current_complexity or "complex")
35508
37034
  _plan_risk = self._resolve_plan_option_risk(chosen)
35509
37035
  try:
@@ -36055,7 +37581,7 @@ body{padding:18px}
36055
37581
  )
36056
37582
  continue
36057
37583
  stop_note = (
36058
- "模型连续多轮仅输出思考而无动作,自动执行已熔断停止(fault_counter>=3)。"
37584
+ "模型连续多轮仅输出思考而无动作,自动执行已熔断停止(fault_counter>=15)。"
36059
37585
  "请尝试拆分任务,或切换更强的推理模型后继续。"
36060
37586
  )
36061
37587
  raise CircuitBreakerTriggered(stop_note)
@@ -36402,6 +37928,7 @@ body{padding:18px}
36402
37928
  self.current_phase = f"tool:{name}"
36403
37929
  self.current_tool_name = name
36404
37930
  round_tool_names.append(name)
37931
+ todo_rows_before = self.todo.snapshot() if name in {"TodoWrite", "TodoWriteRescue"} else None
36405
37932
  args = tc["function"]["arguments"]
36406
37933
  args_error = str(tc.get("args_error", "") or "").strip()
36407
37934
  raw_args = tc.get("raw_arguments")
@@ -36566,15 +38093,41 @@ body{padding:18px}
36566
38093
  recovery_retry_rounds = 0
36567
38094
  if dispatched_name in {"TodoWrite", "TodoWriteRescue"}:
36568
38095
  todo_attempted = True
36569
- state, reason = self._analyze_todo_result(dispatched_name, output)
38096
+ todo_rows_after = self.todo.snapshot()
38097
+ state, reason = self._analyze_todo_result(
38098
+ dispatched_name,
38099
+ output,
38100
+ before_rows=todo_rows_before,
38101
+ after_rows=todo_rows_after,
38102
+ )
36570
38103
  if state == "ok":
36571
38104
  used_todo = True
36572
38105
  self.todo_write_issue_count = 0
36573
38106
  self.todo_last_issue = ""
38107
+ self._emit(
38108
+ "status",
38109
+ {"summary": f"todo updated ({trim(reason, 100)})"},
38110
+ )
36574
38111
  else:
36575
38112
  self.todo_write_issue_count += 1
36576
38113
  self.todo_last_issue = reason
36577
- if self.todo_write_issue_count >= 2 and not self._todo_runtime_has_worker_rows(single_role):
38114
+ self._emit(
38115
+ "status",
38116
+ {
38117
+ "summary": (
38118
+ "todo update produced no progress "
38119
+ f"({trim(reason, 100)})"
38120
+ )
38121
+ },
38122
+ )
38123
+ repeat_no_progress = any(
38124
+ token in str(reason or "").lower()
38125
+ for token in ("repeated", "no progress", "without changing")
38126
+ )
38127
+ if self.todo_write_issue_count >= 2 and (
38128
+ not self._todo_runtime_has_worker_rows(single_role)
38129
+ or repeat_no_progress
38130
+ ):
36578
38131
  self._emit(
36579
38132
  "status",
36580
38133
  {
@@ -36601,6 +38154,22 @@ body{padding:18px}
36601
38154
  "ok": not str(output).startswith("Error:"),
36602
38155
  }
36603
38156
  )
38157
+ # Update blackboard signals (step_files, execution_logs) for plan+single mode.
38158
+ # In plan+sync this is handled by _blackboard_update_from_worker_step, but in
38159
+ # plan+single there is no worker turn — we must update inline so that
38160
+ # _plan_step_has_blackboard_evidence() can see the evidence when the gate fires.
38161
+ try:
38162
+ self._blackboard_update_from_tool_result(
38163
+ "developer",
38164
+ {
38165
+ "name": dispatched_name or name,
38166
+ "args": args if isinstance(args, dict) else {},
38167
+ "output": trim(str(output or ""), 3000),
38168
+ "ok": not str(output).startswith("Error:"),
38169
+ },
38170
+ )
38171
+ except Exception:
38172
+ pass
36604
38173
  # Failure ledger: record tool call and detect errors (single-agent, unified)
36605
38174
  self._ledger_record_tool_call(name, args if isinstance(args, dict) else {})
36606
38175
  _sa_ok = not str(output or "").startswith("Error")
@@ -36909,6 +38478,22 @@ body{padding:18px}
36909
38478
  self.rounds_without_todo += 1
36910
38479
  else:
36911
38480
  self.rounds_without_todo += 1
38481
+ concrete_work_without_todo = (
38482
+ not used_todo
38483
+ and self._todo_runtime_has_worker_rows(single_role)
38484
+ and any(
38485
+ isinstance(r, dict)
38486
+ and r.get("ok", False)
38487
+ and str(r.get("name", "") or "") in {
38488
+ "write_file",
38489
+ "edit_file",
38490
+ "bash",
38491
+ "read_file",
38492
+ "write_to_blackboard",
38493
+ }
38494
+ for r in single_round_tool_results
38495
+ )
38496
+ )
36912
38497
  if (
36913
38498
  todo_attempted
36914
38499
  and not used_todo
@@ -36933,18 +38518,25 @@ body{padding:18px}
36933
38518
  now_tick = now_ts()
36934
38519
  can_remind = (now_tick - self.last_todo_reminder_ts) >= 20
36935
38520
  if can_remind and self.todo_reminder_count < 2:
36936
- if not self._todo_runtime_has_worker_rows(single_role) and self.rounds_without_todo >= 2:
36937
- self.messages.append(
36938
- {
36939
- "role": "user",
36940
- "content": "<reminder>Please call TodoWrite now to update the current subtask before continuing. If it fails/repeats, switch to TodoWriteRescue.</reminder>",
36941
- "ts": now_tick,
36942
- }
38521
+ if concrete_work_without_todo:
38522
+ self._append_plan_guidance_bubble(
38523
+ "<reminder>Update your todos now: finish the current subtask in TodoWrite before moving on.</reminder>",
38524
+ summary="todo reminder",
38525
+ )
38526
+ self.last_todo_reminder_ts = now_tick
38527
+ self.todo_reminder_count += 1
38528
+ elif not self._todo_runtime_has_worker_rows(single_role) and self.rounds_without_todo >= 2:
38529
+ self._append_plan_guidance_bubble(
38530
+ "<reminder>Please call TodoWrite now to update the current subtask before continuing. If it fails/repeats, switch to TodoWriteRescue.</reminder>",
38531
+ summary="todo reminder",
36943
38532
  )
36944
38533
  self.last_todo_reminder_ts = now_tick
36945
38534
  self.todo_reminder_count += 1
36946
38535
  elif self._todo_should_block_auto_continue("") and self.rounds_without_todo >= 4:
36947
- self.messages.append({"role": "user", "content": "<reminder>Update your todos now: finish the current subtask in TodoWrite before moving on.</reminder>", "ts": now_tick})
38536
+ self._append_plan_guidance_bubble(
38537
+ "<reminder>Update your todos now: finish the current subtask in TodoWrite before moving on.</reminder>",
38538
+ summary="todo reminder",
38539
+ )
36948
38540
  self.last_todo_reminder_ts = now_tick
36949
38541
  self.todo_reminder_count += 1
36950
38542
  if manual_compact:
@@ -37038,6 +38630,12 @@ body{padding:18px}
37038
38630
  self._generate_run_completion_summary()
37039
38631
  except Exception:
37040
38632
  pass
38633
+ try:
38634
+ _applied_runtime_updates = self._apply_deferred_runtime_updates()
38635
+ for _note in _applied_runtime_updates[:6]:
38636
+ self._emit("status", {"summary": _note})
38637
+ except Exception:
38638
+ pass
37041
38639
  self._emit("status", {"summary": "run finished"})
37042
38640
  cb = self.run_finished_callback
37043
38641
  if cb:
@@ -38995,7 +40593,7 @@ function renderLlmFields(provider){const container=E('llmFieldsContainer');if(!c
38995
40593
  async function scanOllamaModels(){const urlEl=E('llmF_ollama_url');const sel=E('llmF_ollama_model');const hint=E('ollamaScanHint');const baseUrl=(urlEl?.value||'').trim()||'http://127.0.0.1:11434';if(hint)hint.textContent=t('llm_scanning');try{const res=await fetch('/api/ollama/models?base_url='+encodeURIComponent(baseUrl));const data=await res.json();if(!data.ok||!data.models?.length){if(hint)hint.textContent=t('llm_scan_empty')+(data.error?' ('+data.error+')':'');return}if(sel){sel.innerHTML='';for(const m of data.models){const op=document.createElement('option');op.value=m;op.textContent=m;sel.appendChild(op)}}if(hint)hint.textContent=t('llm_scan_found').replace('{n}',String(data.models.length))}catch(err){if(hint)hint.textContent=t('llm_scan_error')+': '+(err.message||String(err))}}
38996
40594
  async function scanOpenAICompatModels(provider){const scanMap={openai_compat:{urlKey:'openai_url',modelKey:'openai_model',keyKey:'openai_key',defaultUrl:'https://api.openai.com/v1'},siliconflow:{urlKey:'siliconflow_url',modelKey:'siliconflow_model',keyKey:'siliconflow_key',defaultUrl:'https://api.siliconflow.cn/v1'},vllm:{urlKey:'vllm_url',modelKey:'vllm_model',keyKey:'vllm_key',defaultUrl:'http://localhost:8000/v1'},lmstudio:{urlKey:'lmstudio_url',modelKey:'lmstudio_model',keyKey:'lmstudio_key',defaultUrl:'http://localhost:1234/v1'},glm:{urlKey:'glm_url',modelKey:'glm_model',keyKey:'glm_key',defaultUrl:'https://open.bigmodel.cn/api/paas/v4'},kimi:{urlKey:'kimi_url',modelKey:'kimi_model',keyKey:'kimi_key',defaultUrl:'https://api.moonshot.cn/v1'},openrouter:{urlKey:'openrouter_url',modelKey:'openrouter_model',keyKey:'openrouter_key',defaultUrl:'https://openrouter.ai/api/v1'},custom_http:{urlKey:'custom_url',modelKey:'custom_model',keyKey:'custom_key',defaultUrl:''}};const normalizedProvider=String(provider||'openai_compat').trim()||'openai_compat';const meta=scanMap[normalizedProvider]||scanMap.openai_compat;const urlEl=E('llmF_'+meta.urlKey);const modelEl=E('llmF_'+meta.modelKey);const hint=E('localScanHint');const baseUrl=(urlEl?.value||'').trim()||meta.defaultUrl||'';const apiKey=(E('llmF_'+meta.keyKey)?.value||'').trim();if(hint)hint.textContent=t('llm_scanning');try{let url='/api/openai_compat/models?provider='+encodeURIComponent(normalizedProvider)+'&base_url='+encodeURIComponent(baseUrl);if(apiKey)url+='&api_key='+encodeURIComponent(apiKey);const res=await fetch(url);const data=await res.json();const models=Array.isArray(data.models)?data.models.filter(Boolean):[];if(!data.ok){if(hint)hint.textContent=t('llm_scan_error')+(data.error?' ('+data.error+')':'');return}if(models.length){if(modelEl&&!String(modelEl.value||'').trim())modelEl.value=models[0];if(hint)hint.textContent=t('llm_scan_found').replace('{n}',String(models.length))+': '+models.slice(0,3).join(', ');return}if(data.reachable){if(hint)hint.textContent=t('llm_scan_reachable_manual')+(data.error?' ('+data.error+')':'');return}if(hint)hint.textContent=t('llm_scan_empty')+(data.error?' ('+data.error+')':'')}catch(err){if(hint)hint.textContent=t('llm_scan_error')+': '+(err.message||String(err))}}
38997
40595
  function collectLlmConfig(){const provider=E('llmProvider')?.value||'ollama';const config={provider:provider};if(provider==='ollama'){config.ollama_url=(E('llmF_ollama_url')?.value||'').trim()||'http://127.0.0.1:11434';config.ollama_model=E('llmF_ollama_model')?.value||''}else if(provider==='custom_http'){const fields=LLM_PROVIDER_FIELDS.custom_http;for(const f of fields){const el=E('llmF_'+f.key);if(!el)continue;if(f.type==='textarea'){config[f.key]=el.value.trim()}else if(f.key==='temperature'){const v=parseFloat(el.value);if(!isNaN(v))config[f.key]=v}else if(f.key==='request_timeout'){const v=parseInt(el.value,10);if(!isNaN(v)&&v>0)config[f.key]=v}else{config[f.key]=el.value.trim()}}}else{const fields=LLM_PROVIDER_FIELDS[provider]||[];for(const f of fields){const el=E('llmF_'+f.key);if(el){const raw=el.value.trim();config[f.key]=(provider!=='custom_http'&&f.type==='url')?(raw||String(f.placeholder||'').trim()):raw}}}config.thinking_stream=E('llmF_thinking_stream')?.value==='true';return config}
38998
- async function submitLlmConfig(){if(!S.activeId){showError(t('select_session_first'));return}const config=collectLlmConfig();try{const payload={filename:'LLM.config.json',mime:'application/json',content_b64:btoa(unescape(encodeURIComponent(JSON.stringify(config,null,2))))};const out=await api('/api/sessions/'+S.activeId+'/uploads',{method:'POST',body:JSON.stringify(payload)});if(!out?.model_catalog){showError(t('config_uploaded_no_profiles'))}else{showError('')}const cat=out?.model_catalog||await loadModelCatalog();if(!applyModelCatalog(cat)){renderModelControls()}await refreshSnapshot({forceFull:true,allowWhenFrozen:true});E('llmConfigModal').style.display='none'}catch(err){showError(err.message||String(err))}}
40596
+ async function submitLlmConfig(){if(!S.activeId){showError(t('select_session_first'));return}const config=collectLlmConfig();try{const payload={filename:'LLM.config.json',mime:'application/json',content_b64:btoa(unescape(encodeURIComponent(JSON.stringify(config,null,2))))};const out=await api('/api/sessions/'+S.activeId+'/uploads',{method:'POST',body:JSON.stringify(payload)});const note=String(out?.note||out?.model_catalog?.note||'').trim();if(!out?.model_catalog){showError(t('config_uploaded_no_profiles'))}else if(note){showError(note)}else{showError('')}const cat=out?.model_catalog||await loadModelCatalog();if(!applyModelCatalog(cat)){renderModelControls()}await refreshSnapshot({forceFull:true,allowWhenFrozen:true});E('llmConfigModal').style.display='none'}catch(err){showError(err.message||String(err))}}
38999
40597
  function openLlmConfigModal(){const modal=E('llmConfigModal');if(!modal)return;modal.style.display='flex';const prov=E('llmProvider');if(prov){renderLlmFields(prov.value)}}
39000
40598
  const COMPACT_AUTO_REFRESH_COUNT=3;
39001
40599
  const COMPACT_AUTO_REFRESH_INTERVAL_MS=260;
@@ -42020,7 +43618,7 @@ async function renameSession(){if(!S.activeId){showError(t('select_session_first
42020
43618
  async function deleteSession(){if(!S.activeId){showError(t('select_session_first'));return}const deletingId=S.activeId;const ok=confirm(t('delete_confirm'));if(!ok)return;await api('/api/sessions/'+S.activeId,{method:'DELETE'});if(S.previewBySession&&deletingId){delete S.previewBySession[deletingId]}if(S.fileExplorerBySession&&deletingId){delete S.fileExplorerBySession[deletingId]}S.activeId=null;S.snap=null;if(S.es)S.es.close();renderPreviewTabs();renderPreviewVisibility();renderActivePreview(false);await refreshSessions();if(S.sessions.length)await selectSession(S.sessions[0].id)}
42021
43619
  async function applyModel(){const sel=E('modelSelect');const btn=E('applyModelBtn');const model=sel?.value||'';if(!model){showError(t('no_model_selected'));return}if(S.staticMode&&S.frozen)resumeAutoUpdates();S.config=S.config||{};const prevModel=String(S.config.model||'');const prevSnapModel=String(S.snap?.model||'');const prevSnapCatalog=(S.snap&&typeof S.snap==='object')?S.snap.llm_model_catalog:undefined;try{S.config.model=model;if(S.snap&&typeof S.snap==='object'){S.snap.model=_modelNameFromSelection(model)||S.snap.model;if(!S.snap.llm_model_catalog||typeof S.snap.llm_model_catalog!=='object')S.snap.llm_model_catalog={};S.snap.llm_model_catalog.selected=model}renderModelControls();renderStats();if(S.snap)renderBoards();if(sel)sel.disabled=true;if(btn)btn.disabled=true;const path=S.activeId?('/api/sessions/'+S.activeId+'/config/model'):'/api/config/model';const changed=await api(path,{method:'POST',body:JSON.stringify({selection:model,model})});if(changed?.note)showError(changed.note);else showError('');if(!applyModelCatalog(changed)){const cat=await loadModelCatalog();if(!applyModelCatalog(cat)){S.config.model=String(changed?.selected||model||'').trim();renderModelControls()}}if(S.snap&&typeof S.snap==='object'){const selected=String(S.config?.model||model||'').trim();const modelName=_modelNameFromSelection(selected);if(modelName)S.snap.model=modelName;if(changed&&typeof changed==='object')S.snap.llm_model_catalog=changed;renderBoards()}scheduleSnapshot({forceFull:true,delayMs:40,allowWhenFrozen:true})}catch(err){S.config.model=prevModel;if(S.snap&&typeof S.snap==='object'){if(prevSnapModel)S.snap.model=prevSnapModel;if(prevSnapCatalog!==undefined)S.snap.llm_model_catalog=prevSnapCatalog;renderBoards()}renderModelControls();renderStats();showError(err.message||String(err))}finally{if(sel)sel.disabled=false;if(btn)btn.disabled=false}}
42022
43620
 
42023
- async function uploadLlmConfigFile(file){try{if(!S.activeId){showError(t('select_session_first'));return}if(!file){return}const arr=await file.arrayBuffer();const payload={filename:'LLM.config.json',mime:file.type||'application/json',content_b64:ab2b64(arr)};const out=await api('/api/sessions/'+S.activeId+'/uploads',{method:'POST',body:JSON.stringify(payload)});if(!out?.model_catalog){showError(t('config_uploaded_no_profiles'));}else{showError('');const modal=E('llmConfigModal');if(modal)modal.style.display='none'}const cat=out?.model_catalog||await loadModelCatalog();if(!applyModelCatalog(cat)){renderModelControls()}await refreshSnapshot({forceFull:true,allowWhenFrozen:true})}catch(err){showError(err.message||String(err))}}
43621
+ async function uploadLlmConfigFile(file){try{if(!S.activeId){showError(t('select_session_first'));return}if(!file){return}const arr=await file.arrayBuffer();const payload={filename:'LLM.config.json',mime:file.type||'application/json',content_b64:ab2b64(arr)};const out=await api('/api/sessions/'+S.activeId+'/uploads',{method:'POST',body:JSON.stringify(payload)});const note=String(out?.note||out?.model_catalog?.note||'').trim();if(!out?.model_catalog){showError(t('config_uploaded_no_profiles'));}else{showError(note||'');const modal=E('llmConfigModal');if(modal)modal.style.display='none'}const cat=out?.model_catalog||await loadModelCatalog();if(!applyModelCatalog(cat)){renderModelControls()}await refreshSnapshot({forceFull:true,allowWhenFrozen:true})}catch(err){showError(err.message||String(err))}}
42024
43622
  async function sendMessage(){showError('');const t=E('prompt').value.trim();if(!t||!S.activeId)return;if(S.staticMode&&S.frozen)resumeAutoUpdates();E('prompt').value='';try{await waitForPendingUploads();await api('/api/sessions/'+S.activeId+'/message',{method:'POST',body:JSON.stringify({content:t})});S.lastDeltaTs=Date.now();if(!S.es||S.es.readyState===2){scheduleSnapshot({forceFull:false,delayMs:120,allowWhenFrozen:true})}}catch(err){showError(err.message)}}
42025
43623
  async function interruptRun(){if(!S.activeId)return;if(S.staticMode&&S.frozen)resumeAutoUpdates();await api('/api/sessions/'+S.activeId+'/interrupt',{method:'POST'});S.lastDeltaTs=Date.now();if(!S.es||S.es.readyState===2){scheduleSnapshot({forceFull:false,delayMs:140,allowWhenFrozen:true})}}
42026
43624
  async function compactNow(){if(!S.activeId)return;if(S.staticMode&&S.frozen)resumeAutoUpdates();await api('/api/sessions/'+S.activeId+'/compact',{method:'POST'});S.lastDeltaTs=Date.now();scheduleCompactRefreshBurst(COMPACT_AUTO_REFRESH_COUNT);if(!S.es||S.es.readyState===2){scheduleSnapshot({forceFull:false,delayMs:180,allowWhenFrozen:true})}}
@@ -50708,6 +52306,14 @@ class AppContext:
50708
52306
  return started
50709
52307
 
50710
52308
  def _on_session_run_finished(self, user_id: str, session_id: str):
52309
+ try:
52310
+ mgr = self.manager_for_user(user_id)
52311
+ sess = mgr.get(session_id)
52312
+ if sess and bool(getattr(sess, "_deferred_runtime_sync_requested", False)):
52313
+ mgr._sync_from_session(sess, apply_to_all=False)
52314
+ sess._deferred_runtime_sync_requested = False
52315
+ except Exception:
52316
+ pass
50711
52317
  if not self.scheduler_limits_enabled():
50712
52318
  return
50713
52319
  started_rows: list[dict] = []
@@ -52319,9 +53925,26 @@ class Handler(BaseHTTPRequestHandler):
52319
53925
  if not selection:
52320
53926
  return self._send_json({"error": "selection required"}, status=400)
52321
53927
  model_override = payload.get("model_override")
53928
+ if bool(getattr(sess, "running", False)):
53929
+ try:
53930
+ sess._queue_deferred_runtime_update(
53931
+ "model_selection",
53932
+ {
53933
+ "selection": selection,
53934
+ "model_override": model_override if isinstance(model_override, str) else "",
53935
+ },
53936
+ )
53937
+ except Exception as exc:
53938
+ return self._send_json({"error": str(exc)}, status=400)
53939
+ queued = sess.model_catalog()
53940
+ queued["queued"] = True
53941
+ queued["note"] = (
53942
+ "session is running; model switch queued and will apply after the current run finishes"
53943
+ )
53944
+ return self._send_json(queued)
52322
53945
  try:
52323
53946
  out = sess.set_runtime_selection(selection, model_override if isinstance(model_override, str) else None)
52324
- mgr._sync_from_session(sess, apply_to_all=True)
53947
+ mgr._sync_from_session(sess, apply_to_all=False)
52325
53948
  except Exception as exc:
52326
53949
  return self._send_json({"error": str(exc)}, status=400)
52327
53950
  return self._send_json(out)
@@ -52420,9 +54043,9 @@ class Handler(BaseHTTPRequestHandler):
52420
54043
  if len(raw) > 20 * 1024 * 1024:
52421
54044
  return self._send_json({"error": "max upload size is 20MB"}, status=413)
52422
54045
  meta = sess.add_upload(filename, raw, mime)
52423
- if isinstance(meta.get("model_catalog"), dict):
54046
+ if isinstance(meta.get("model_catalog"), dict) and not bool(meta.get("model_catalog", {}).get("queued")):
52424
54047
  try:
52425
- mgr._sync_from_session(sess, apply_to_all=True)
54048
+ mgr._sync_from_session(sess, apply_to_all=False)
52426
54049
  except Exception:
52427
54050
  pass
52428
54051
  return self._send_json(meta, status=201)
@@ -52516,16 +54139,16 @@ class Handler(BaseHTTPRequestHandler):
52516
54139
  explicit_complexity = infer_user_complexity_value(
52517
54140
  str(body.get("complexity", body.get("task_complexity", "")) or "")
52518
54141
  )
52519
- current_complexity = trim(
52520
- str(getattr(sess, "runtime_task_complexity", "") or "").strip().lower(),
52521
- 20,
54142
+ current_complexity = normalize_task_complexity(
54143
+ getattr(sess, "runtime_task_complexity", "") or "",
54144
+ default="",
52522
54145
  )
52523
54146
  if explicit_complexity in TASK_COMPLEXITY_LEVELS:
52524
- sess.runtime_task_complexity = explicit_complexity
54147
+ sess.runtime_task_complexity = normalize_task_complexity(explicit_complexity, default="")
52525
54148
  elif current_complexity in TASK_COMPLEXITY_LEVELS:
52526
54149
  sess.runtime_task_complexity = current_complexity
52527
54150
  else:
52528
- sess.runtime_task_complexity = str(policy.get("complexity", "simple"))
54151
+ sess.runtime_task_complexity = normalize_task_complexity(policy.get("complexity", "simple"), default="simple")
52529
54152
  sess.runtime_scale_preference = "thorough" if level >= 4 else "balanced"
52530
54153
  return self._send_json({"task_level": level})
52531
54154
  return self._send_json({"error": "not found"}, status=404)