codebee 0.1.10 → 0.1.11

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.
@@ -19,7 +19,7 @@ import re
19
19
  import threading
20
20
  import time
21
21
 
22
- from . import catalog, history, jobs, manager, modelhub, mocks, planner, registry, router, runner, skills, store, usage
22
+ from . import aiflavor, catalog, history, jobs, manager, modelhub, mocks, planner, registry, router, runner, skills, store, usage
23
23
  from . import builtin_agent
24
24
  from . import diagnostics
25
25
  from . import paths as paths_mod
@@ -730,6 +730,118 @@ def _format_issues(review_json, verify_pass, verify_failed_note):
730
730
 
731
731
  # ---------------------------------------------------------------- 代码流水线
732
732
 
733
+ def _shortstat_files(diffstat_line):
734
+ """` 3 files changed, 10 insertions(+)` → 文件数(解析失败给大数排最后)。"""
735
+ import re as _re
736
+ m = _re.search(r"(\d+) files? changed", diffstat_line or "")
737
+ return int(m.group(1)) if m else 9999
738
+
739
+
740
+ def _code_bestof(run, task, impl, difficulty, ev):
741
+ """代码任务 Best-of-N(借鉴 orca 并行 worktree 择优)。启用条件:best_of≥2、
742
+ 无续会话、非手动指定实现者、工作目录是 git 仓库。
743
+
744
+ 按基线(git_rev 或 HEAD)建 N 个临时 worktree → 各路并行跑实现子任务链 →
745
+ 各跑验证命令 → 择优(验证通过 > 改动文件更少)→ 胜者 diff 应用回主工作区。
746
+ 任一环节失败一律返回 False,由调用方回落原单路实现(绝不因赛马挡任务)。"""
747
+ from . import gitmod
748
+ run_id = run["id"]
749
+ n = max(2, min(3, int(task.get("best_of") or 1)))
750
+ wd = task["workdir"]
751
+ if not gitmod._git(wd, "rev-parse", "--is-inside-work-tree")["ok"]:
752
+ return False
753
+ base = task.get("git_rev") or ""
754
+ if base and not gitmod.valid_rev(base):
755
+ return False
756
+ base_rev = (gitmod._git(wd, "rev-parse", base)["stdout"].strip() if base
757
+ else gitmod._git(wd, "rev-parse", "HEAD")["stdout"].strip())
758
+ if not base_rev:
759
+ return False
760
+ wts = []
761
+ results = {}
762
+
763
+ def _cleanup():
764
+ for k, br, wt_path in wts:
765
+ gitmod._git(wd, "worktree", "remove", "--force", wt_path, timeout=60)
766
+ gitmod._git(wd, "branch", "-D", br, timeout=60)
767
+
768
+ try:
769
+ import tempfile
770
+ race_root = os.path.join(tempfile.gettempdir(), "codebee-race", run_id)
771
+ os.makedirs(race_root, exist_ok=True)
772
+ for k in range(n):
773
+ br = "codebee-race-%s-%d" % (run_id, k)
774
+ wt_path = os.path.join(race_root, "v%d" % k)
775
+ r = gitmod._git(wd, "worktree", "add", "--detach", wt_path, base_rev, timeout=120)
776
+ if not r["ok"]:
777
+ raise RuntimeError(r["stderr"] or "worktree add 失败")
778
+ wts.append((k, br, wt_path))
779
+
780
+ subtasks = [{"title": task.get("title") or task.get("goal") or "实现",
781
+ "detail": task.get("goal") or ""}]
782
+
783
+ def _race_one(k, wt_path):
784
+ agt_b = modelhub.bind_agent(impl, difficulty)
785
+ res = None
786
+ for i, sub in enumerate(subtasks):
787
+ prompt = (CODE_IMPL_PROMPT
788
+ .replace("__GOAL__", task["goal"])
789
+ .replace("__SUBTASK__", sub["detail"] if sub["detail"] else sub["title"])
790
+ .replace("__CONTEXT__", task.get("context") or "(无)")
791
+ .replace("__VERIFY_HINT__", _verify_hint(task)))
792
+ res = _run_step(run_id, "race%d-impl" % k, agt_b, prompt, wt_path,
793
+ readonly=False, ev=ev,
794
+ note="赛马路 %d/%d(worktree 隔离)" % (k + 1, n),
795
+ images=_task_images(task, wd), require_tools=True)
796
+ if not res["ok"]:
797
+ break
798
+ v_pass = bool(res and res["ok"])
799
+ if v_pass and task.get("verify_command"):
800
+ r = runner.run_process(shell_cmd=task["verify_command"], cwd=wt_path,
801
+ timeout=600, cancel_event=ev)
802
+ v_pass = bool(r["ok"])
803
+ diffstat = (gitmod._git(wt_path, "diff", "--shortstat", base_rev)["stdout"] or "").strip()
804
+ results[k] = {"ok": bool(res and res["ok"]), "verify": v_pass,
805
+ "wt": wt_path, "diffstat": diffstat}
806
+
807
+ threads = []
808
+ for k, br, wt_path in wts:
809
+ th = threading.Thread(target=_race_one, args=(k, wt_path),
810
+ name="codebestof-%s-%d" % (run_id, k), daemon=True)
811
+ threads.append(th)
812
+ th.start()
813
+ for th in threads:
814
+ th.join(3600)
815
+ _check_cancel(ev)
816
+
817
+ usable = [r for k, r in sorted(results.items()) if r["ok"] and r["verify"]]
818
+ if not usable:
819
+ return False # 全败:回落单路实现(保持原有换将/报错行为)
820
+ usable.sort(key=lambda r: _shortstat_files(r["diffstat"]))
821
+ winner = usable[0]
822
+ diff = gitmod._git(winner["wt"], "diff", base_rev, timeout=120)
823
+ if diff["ok"] and diff["stdout"].strip():
824
+ apply_r = runner.run_process(
825
+ argv=["git", "apply", "--whitespace=nowarn"],
826
+ cwd=wd, timeout=60, stdin_text=diff["stdout"])
827
+ if not apply_r["ok"]:
828
+ return False
829
+ store.update_run(run_id, bestof={
830
+ "kind": "code-worktree", "candidates": len(results),
831
+ "winner_files": _shortstat_files(winner["diffstat"]),
832
+ "diffstat": winner["diffstat"],
833
+ })
834
+ return True
835
+ except Exception:
836
+ log.warning("code bestof aborted run=%s", run_id, exc_info=True)
837
+ return False
838
+ finally:
839
+ try:
840
+ _cleanup()
841
+ except Exception:
842
+ pass
843
+
844
+
733
845
  def _run_code(run, task, agents, ev, stats, mode):
734
846
  run_id = run["id"]
735
847
  workdir = task["workdir"]
@@ -842,6 +954,13 @@ def _run_code(run, task, agents, ev, stats, mode):
842
954
  ok, res = _run_one(impl_agent)
843
955
  if ok:
844
956
  return True
957
+ # 单路实现失败:Best-of-N 赛马兜底(借鉴 orca worktree 择优)——多路并行
958
+ # 各自 worktree 隔离实现+验证,胜者 diff 回主工作区;失败回落走换将
959
+ if (mode == "auto" and impl_agent.get("mode") == "real"
960
+ and max(1, min(3, int(task.get("best_of") or 1))) >= 2
961
+ and resume_ctx is None
962
+ and _code_bestof(run, task, impl_agent, difficulty, ev)):
963
+ return True
845
964
  # 实现步失败不立刻判死:2026-09-16 实测配额烧干时 5 连跑全在同一条 CLI 上
846
965
  # 失败收场,而健康的 opencode 一直在旁观望——跨 CLI 换将重试一次
847
966
  # (mode=manual 尊重用户指定,不换)。
@@ -957,6 +1076,7 @@ def _run_code(run, task, agents, ev, stats, mode):
957
1076
  lines.append("(无)")
958
1077
  lines += ["", "## 评审总评", "", review_json.get("summary", ""), ""]
959
1078
  store.write_report(run_id, "\n".join(lines))
1079
+ _write_task_evidence(run_id, task, workdir, _evidence_lines_from_run(run_id, task))
960
1080
  store.update_run(run_id, status="done", verdict=verdict,
961
1081
  summary="代码任务%s(验证%s / 评审%s%s)" % (
962
1082
  "通过" if overall_pass else "未通过",
@@ -1224,6 +1344,7 @@ def _run_direct(run, task, agents, ev, stats, mode):
1224
1344
  if last_text:
1225
1345
  report += ["## 最近一轮输出", "", last_text[-5000:], ""]
1226
1346
  store.write_report(run_id, "\n".join(report))
1347
+ _write_task_evidence(run_id, task, workdir, _evidence_lines_from_run(run_id, task))
1227
1348
  store.update_run(run_id, status="done", verdict=verdict,
1228
1349
  summary="直连完成(%d 轮):%s" % (turns, last_text[:160]),
1229
1350
  ended_at=_now())
@@ -1292,6 +1413,8 @@ def _tpl(task, key, default):
1292
1413
 
1293
1414
  BIBLE_FILE = "story-bible.md"
1294
1415
  _BIBLE_MAX_CHARS = 20000
1416
+ MODULES_FILE = "plot-modules.md"
1417
+ _MODULES_MAX_CHARS = 12000
1295
1418
 
1296
1419
  # 评审视角播种(dev-3.0 式 bug hunters):N 个评审各领一个深挖镜头,
1297
1420
  # 避免全员盯着同一处。按评审序号取模分配——同一评审每轮同一镜头,
@@ -1321,6 +1444,23 @@ def _story_bible(workdir):
1321
1444
  "与其冲突处以圣经为准)\n\n" + txt)
1322
1445
 
1323
1446
 
1447
+ def _plot_modules(workdir):
1448
+ """剧情模块库(oh-story 拆文沉淀式):工作目录里的 plot-modules.md
1449
+ (可复用的桥段/冲突/爽点/名场面素材模块),作者手工维护,每章起草与
1450
+ 评审前自动注入。不存在/为空返回 ""——约定式功能,零配置零噪音。"""
1451
+ p = os.path.abspath(os.path.join(str(workdir or ""), MODULES_FILE))
1452
+ if not _inside(workdir, p) or not os.path.isfile(p):
1453
+ return ""
1454
+ try:
1455
+ txt = _read_text_any_enc(p)[:_MODULES_MAX_CHARS].strip()
1456
+ except OSError:
1457
+ return ""
1458
+ if not txt:
1459
+ return ""
1460
+ return ("## 剧情模块库(plot-modules.md:可复用的桥段/冲突/爽点素材模块,"
1461
+ "鼓励化用,不要照抄原句)\n\n" + txt)
1462
+
1463
+
1324
1464
  def _critic_lens(critics, agent):
1325
1465
  """该评审的专属视角;单评审/手动指定时不播种(无从轮换,也别稀释注意力)。"""
1326
1466
  try:
@@ -1521,6 +1661,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1521
1661
  start = int(serial.get("start_chapter") or 1)
1522
1662
  # 故事圣经:工作目录里的 story-bible.md,整个 run 内字节稳定(前缀缓存友好)
1523
1663
  bible = _story_bible(workdir)
1664
+ # 剧情模块库:plot-modules.md(拆文沉淀的可复用素材模块)并入同一注入块,
1665
+ # 同样要求 run 内字节稳定;无模块库时零噪音
1666
+ mods = _plot_modules(workdir)
1667
+ if mods:
1668
+ bible = (bible + "\n\n" + mods) if bible else mods
1524
1669
 
1525
1670
 
1526
1671
  def crit_prompt_for(text, note=""):
@@ -1689,6 +1834,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1689
1834
  critic_sids = {} # §07 T1.1:每评审的会话 id(第 2 轮复用,前缀走缓存读)
1690
1835
  race_cj = None # 变体赛马已评审胜者:直接作为第 1 轮结果,不重评
1691
1836
  race_scored = 0
1837
+ race_losers = [] # 赛马败稿文本(收卷前留存):首轮修订时提炼败者精华
1692
1838
 
1693
1839
  reuse = i in done_set and os.path.exists(os.path.join(workdir, ch_file))
1694
1840
  if reuse:
@@ -1914,6 +2060,13 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1914
2060
  error="第 %d 章赛马收卷失败: %r" % (i, e), ended_at=_now())
1915
2061
  return
1916
2062
  for v in scored_variants[1:]:
2063
+ # 败者精华:删除前留存文本,供首轮修订提炼(懒调用)
2064
+ try:
2065
+ race_losers.append({
2066
+ "variant": v["variant"],
2067
+ "text": (_read_text_any_enc(os.path.join(workdir, v["file"])) or "")[:8000]})
2068
+ except OSError:
2069
+ pass
1917
2070
  try:
1918
2071
  os.remove(os.path.join(workdir, v["file"]))
1919
2072
  except OSError:
@@ -1966,6 +2119,27 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1966
2119
  crit_lines = ["- %s:%.1f(章阈值 %.1f)" % (d, means[d], threshold_ch) for d in dims]
1967
2120
  crit_lines += ["- [%s] %s" % (x.get("dim", "?"), str(x.get("note", ""))[:140])
1968
2121
  for x in majors]
2122
+ if rnd == 1 and race_losers and impl.get("mode") == "real":
2123
+ # 赛马败者精华回收(连载版):一次选择器调用提炼落选稿优点,
2124
+ # 与评审意见一并喂给首轮修订;失败静默不影响修订
2125
+ try:
2126
+ cands = "### 胜者稿(已选定)\n\n%s" % _read_chapter(workdir, i)[:8000]
2127
+ for li, lo in enumerate(race_losers, 1):
2128
+ cands += "\n\n### 落选稿 %d\n\n%s" % (li, lo["text"])
2129
+ sel_prompt = (BESTOF_SELECTOR_PROMPT
2130
+ .replace("__GOAL__", task["goal"])
2131
+ .replace("__CANDIDATES__", cands))
2132
+ sel = _run_step(run_id, "race-select-c%d" % i,
2133
+ modelhub.bind_agent(critics[0] if critics else impl, difficulty),
2134
+ sel_prompt, step_wd, readonly=True, ev=ev,
2135
+ note="赛马败者精华提炼")
2136
+ _selcj = runner.extract_json(sel.get("text") or "")
2137
+ _imp = str((_selcj or {}).get("improvements") or "").strip()
2138
+ if _imp:
2139
+ crit_lines.append("- [赛马精华] 终审从落选候选稿提炼出值得吸收的优点:%s"
2140
+ % _imp[:400])
2141
+ except Exception:
2142
+ pass
1969
2143
  if impl.get("mode") == "mock":
1970
2144
  step, log_abs = store.add_step(run_id, "revise-c%d" % i, impl["id"],
1971
2145
  impl.get("label"))
@@ -2198,6 +2372,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2198
2372
  else:
2199
2373
  lines.append("(无 major 问题)")
2200
2374
  store.write_report(run_id, "\n".join(lines))
2375
+ _write_task_evidence(run_id, task, workdir, _evidence_lines_from_run(run_id, task))
2201
2376
  store.update_run(run_id, status="done", verdict=verdict,
2202
2377
  summary="连载任务%s(%s,约 %d 字,综合 %.1f)" % (
2203
2378
  "达标" if publishable else "未达标", scope_txt,
@@ -2429,6 +2604,11 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2429
2604
  _tpl(task, "critique_prompt", NOVEL_CRITIQUE_PROMPT))
2430
2605
  .replace("__DIMKEYS__", dimkey)
2431
2606
  .replace("__MANUSCRIPT__", manuscript or "(稿件为空!)"))
2607
+ # AI 味确定性检测(借鉴 oh-story 去AI味):客观参考线随评审下发,
2608
+ # 命中才追加——评审官结合上下文判断是否真问题,脚本不直接扣分
2609
+ _aiflavor_line = aiflavor.report_line(manuscript)
2610
+ if _aiflavor_line:
2611
+ crit_prompt += "\n\n## 确定性检测结果(供评审参考)\n" + _aiflavor_line
2432
2612
  for agent in critics:
2433
2613
  role = "critique-r%d" % r
2434
2614
  if agent.get("mode") == "mock":
@@ -2538,6 +2718,7 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2538
2718
  lines.append("(无)")
2539
2719
  lines += ["", "## 稿件位置", "", "`%s`" % ms_path, ""]
2540
2720
  store.write_report(run_id, "\n".join(lines))
2721
+ _write_task_evidence(run_id, task, workdir, _evidence_lines_from_run(run_id, task))
2541
2722
  store.update_run(run_id, status="done", verdict=verdict,
2542
2723
  summary="评审任务%s(综合 %.1f)" % ("达标" if publishable else "未达标", overall),
2543
2724
  ended_at=_now())
@@ -2593,6 +2774,103 @@ def _run_serial_qa(run, task, agents, ev):
2593
2774
  error="答疑失败(执行/评审链不可用)——" + ";".join(errors[-3:]))
2594
2775
 
2595
2776
 
2777
+ def _write_task_spec(task, workdir):
2778
+ """任务规格落盘 .codebee/spec.md(借鉴 agent-orchestrator 的 .spec/PROMPT.md 与
2779
+ planning-with-files 的文件化计划):任务定义随工作目录留存、随任务分支版本化,
2780
+ 追话/复盘/续跑时可见原始意图。失败静默返回空串——规格文件永远不能挡住任务执行。"""
2781
+ try:
2782
+ from pathlib import Path as _P
2783
+ root = _P(workdir).resolve()
2784
+ target = (root / ".codebee" / "spec.md").resolve()
2785
+ if root not in target.parents: # 守卫:spec 必须落在工作目录内(../、symlink 出逃弃写)
2786
+ return ""
2787
+ os.makedirs(str(target.parent), exist_ok=True)
2788
+ lines = [
2789
+ "# 任务规格", "",
2790
+ "- 标题:%s" % (task.get("title") or ""),
2791
+ "- 类型:%s" % (task.get("type") or ""),
2792
+ "- 创建:%s" % (task.get("created_at") or ""),
2793
+ "- 目标:%s" % str(task.get("goal") or "").replace("\n", " "),
2794
+ ]
2795
+ if task.get("context"):
2796
+ lines.append("- 背景:%s" % str(task["context"]).replace("\n", " "))
2797
+ if task.get("difficulty"):
2798
+ lines.append("- 难度:%s" % task["difficulty"])
2799
+ if task.get("mode"):
2800
+ lines.append("- 路由模式:%s" % task["mode"])
2801
+ label = {"rounds": "评审轮数", "threshold": "发布阈值", "best_of": "赛马候选数"}
2802
+ for k in ("rounds", "threshold", "best_of"):
2803
+ if task.get(k) is not None:
2804
+ lines.append("- %s:%s" % (label[k], task[k]))
2805
+ if task.get("rubric"):
2806
+ lines.append("- 评审维度:%s" % "、".join(task["rubric"]))
2807
+ if task.get("serial"):
2808
+ s = task["serial"]
2809
+ lines.append("- 连载:%s 章 × %s 字(赛马变体 %s)"
2810
+ % (s.get("chapters"), s.get("words_per_chapter"), s.get("variants", 1)))
2811
+ if task.get("verify_command"):
2812
+ lines.append("- 验证命令:`%s`" % task["verify_command"])
2813
+ if not str(target).startswith(str(root) + os.sep): # sink 侧复检:路径必须仍在工作目录内
2814
+ return ""
2815
+ with open(str(target), "w", encoding="utf-8") as f:
2816
+ f.write("\n".join(lines) + "\n")
2817
+ return str(target)
2818
+ except Exception:
2819
+ return ""
2820
+
2821
+
2822
+ def _write_task_evidence(run_id, task, workdir, summary_lines):
2823
+ """验证证据持久化(借鉴 gsd-pi 的 validation evidence):run 收尾把确定性
2824
+ 证据追加进 .codebee/evidence.md——验证命令结果、评审分数、AI 味检测等。
2825
+ 与 spec.md(任务意图)呼应成「任务档案」;失败静默,绝不挡收尾。"""
2826
+ if not summary_lines:
2827
+ return ""
2828
+ try:
2829
+ spec_dir = os.path.join(workdir, ".codebee")
2830
+ os.makedirs(spec_dir, exist_ok=True)
2831
+ path = os.path.join(spec_dir, "evidence.md")
2832
+ header_needed = not os.path.exists(path)
2833
+ with open(path, "a", encoding="utf-8") as f:
2834
+ if header_needed:
2835
+ f.write("# 验证证据(每次运行追加一节)\n\n")
2836
+ f.write("## %s · run %s\n\n" % (_now(), run_id))
2837
+ for ln in summary_lines:
2838
+ f.write("- %s\n" % str(ln)[:300])
2839
+ f.write("\n")
2840
+ return path
2841
+ except Exception:
2842
+ return ""
2843
+
2844
+
2845
+ def _evidence_lines_from_run(run_id, task):
2846
+ """从 run 步骤与 verdict 提取证据行(确定性事实,不抄模型输出)。"""
2847
+ run = store.get_run(run_id) or {}
2848
+ lines = []
2849
+ for s in run.get("steps") or []:
2850
+ role = str(s.get("role") or "")
2851
+ if role == "verify":
2852
+ lines.append("验证命令 `%s` → %s%s" % (
2853
+ task.get("verify_command") or "", s.get("status"),
2854
+ "(%s)" % s.get("summary") if s.get("summary") else ""))
2855
+ verdict = run.get("verdict") or {}
2856
+ if verdict:
2857
+ means = verdict.get("scores") or {}
2858
+ if means:
2859
+ lines.append("评审均分:%s(阈值 %s,%s)" % (
2860
+ "、".join("%s %.1f" % (d, v) for d, v in means.items()),
2861
+ verdict.get("threshold"),
2862
+ "达标" if verdict.get("publishable") else "未达标"))
2863
+ if verdict.get("overall") is not None:
2864
+ lines.append("综合分 %.1f / %d 轮" % (verdict.get("overall") or 0.0,
2865
+ verdict.get("rounds_used") or 0))
2866
+ bestof = run.get("bestof")
2867
+ if bestof:
2868
+ lines.append("赛马:%s(胜者 %s)" % (
2869
+ bestof.get("kind") or "内容候选",
2870
+ bestof.get("pick", bestof.get("winner_files", "?"))))
2871
+ return lines
2872
+
2873
+
2596
2874
  def execute_run(run_id):
2597
2875
  run = store.get_run(run_id)
2598
2876
  if not run:
@@ -2627,6 +2905,9 @@ def execute_run(run_id):
2627
2905
  store.update_run(run_id, git=gitinfo)
2628
2906
  # 任务分支裁决状态:新一轮 run 产生新分支内容,重置回「待裁决」
2629
2907
  store.set_task_git_state(task["id"], "isolated")
2908
+ # 任务规格文件化(借鉴 planning-with-files/agent-orchestrator):任何任务都在
2909
+ # 工作目录留一份 .codebee/spec.md——原始意图可见、随任务分支版本化
2910
+ _write_task_spec(task, task["workdir"])
2630
2911
  agents = _agents()
2631
2912
  global _CURRENT_AGENTS
2632
2913
  _CURRENT_AGENTS = agents
@@ -0,0 +1,7 @@
1
+ # -*- coding: utf-8 -*-
2
+ """一键发布:CDP 驱动平台后台(番茄/七猫)建书与章节发布。
3
+
4
+ 分层:ws.py(最小 WebSocket)→ browser.py(CDP 驱动)→ ledger.py(台账)
5
+ → 平台流程(数据驱动的步骤表,selector 可外置覆盖)→ manager.py(状态机)。
6
+ """
7
+ from . import browser, ledger # noqa: F401