codebee 0.1.46 → 0.1.48

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.
package/app/core/flows.py CHANGED
@@ -13,7 +13,7 @@ import json
13
13
  import re
14
14
  import threading
15
15
 
16
- from . import paths
16
+ from . import paths, volumes
17
17
 
18
18
  _LOCK = threading.RLock()
19
19
  _FILE = paths.DATA_DIR / "flows.json"
@@ -149,10 +149,19 @@ def _norm_serial(serial):
149
149
  if not isinstance(serial, dict) or not serial.get("chapters"):
150
150
  return None
151
151
  try:
152
- return {"chapters": max(2, min(20, int(serial["chapters"]))),
153
- "words_per_chapter": max(500, min(8000, int(serial.get("words_per_chapter") or 2500)))}
152
+ out = {"chapters": max(2, min(20, int(serial["chapters"]))),
153
+ "words_per_chapter": max(500, min(8000, int(serial.get("words_per_chapter") or 2500)))}
154
154
  except Exception:
155
155
  return None
156
+ # 分卷(可选):显式卷表优先,其次每卷章数。流程模板里可以预置,
157
+ # 任务级 payload 仍可覆盖/关闭(0 = 不分卷)。
158
+ spec = volumes.norm_spec(serial.get("volumes"))
159
+ if spec:
160
+ out["volumes"] = spec
161
+ per = volumes.norm_per(serial.get("volume_chapters"))
162
+ if per:
163
+ out["volume_chapters"] = per
164
+ return out
156
165
 
157
166
 
158
167
  def _norm_rubric(rubric):
@@ -0,0 +1,156 @@
1
+ # -*- coding: utf-8 -*-
2
+ """任务生命周期钩子(借鉴 ZCode hooks,收窄为三个事件——2026-09-22 用户立项):
3
+
4
+ - task_start 任务开跑前(编排/直连都触发):stdout 可注入文本,追加进任务上下文
5
+ - message_submit 用户消息送达前:stdout 可注入文本,随消息进提示词头
6
+ - run_end 每轮运行结束:纯副作用型(回写知识库等),输出被忽略
7
+
8
+ 协议:钩子命令从 stdin 收 JSON(事件+上下文);stdout 若为 {"inject": "..."}
9
+ 取该字段为注入文本,否则把非空 stdout 整体当注入文本。
10
+ 失败/超时一律静默跳过(外部脚本不稳绝不拖死编排主流程)。
11
+
12
+ 命令执行:shlex 分词(保 Windows 反斜杠)后走参数列表,不经 shell——
13
+ 不支持管道/重定向等 shell 语法,需要时写 .py/.bat 包装脚本。
14
+ 配置:DATA_DIR/hooks.json,纯本机设置。"""
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import shlex
20
+ import subprocess
21
+
22
+ from . import paths
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ EVENTS = ("task_start", "message_submit", "run_end")
27
+ _DEFAULT_TIMEOUT = 10
28
+ _FILE = None # 测试重绑(同 modelhub._FILE 惯例)
29
+
30
+ _CREATE_NO_WINDOW = 0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
31
+
32
+
33
+ def _path():
34
+ return _FILE or (paths.DATA_DIR / "hooks.json")
35
+
36
+
37
+ def _default_list():
38
+ return []
39
+
40
+
41
+ def load_list():
42
+ """读钩子配置列表;损坏/缺省回落空表。"""
43
+ try:
44
+ data = json.loads(_path().read_text(encoding="utf-8"))
45
+ return data if isinstance(data, list) else _default_list()
46
+ except Exception:
47
+ return _default_list()
48
+
49
+
50
+ def save_list(items):
51
+ """整表保存(UI 全量提交)。字段归一 + 事件白名单。"""
52
+ if not isinstance(items, list):
53
+ raise ValueError("hooks 必须是数组")
54
+ out = []
55
+ for i, h in enumerate(items):
56
+ if not isinstance(h, dict):
57
+ continue
58
+ name = str(h.get("name") or "").strip()[:60]
59
+ event = str(h.get("event") or "").strip()
60
+ cmd = str(h.get("cmd") or "").strip()
61
+ if not cmd:
62
+ continue
63
+ if event not in EVENTS:
64
+ raise ValueError("第 %d 条钩子 event 非法(%s)" % (i + 1, event))
65
+ try:
66
+ timeout = max(1, min(120, int(h.get("timeout_s") or _DEFAULT_TIMEOUT)))
67
+ except (TypeError, ValueError):
68
+ timeout = _DEFAULT_TIMEOUT
69
+ out.append({"id": str(h.get("id") or ("hk-%d" % (i + 1)))[:40],
70
+ "name": name or ("钩子 %d" % (i + 1)),
71
+ "event": event, "cmd": cmd,
72
+ "enabled": bool(h.get("enabled", True)),
73
+ "timeout_s": timeout})
74
+ tmp = _path().with_suffix(".tmp")
75
+ tmp.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
76
+ tmp.replace(_path())
77
+ return out
78
+
79
+
80
+ def _argv(cmd):
81
+ """命令串 → 参数列表。Windows 反斜杠路径保真(posix=False),支持引号分组;
82
+ 分词后剥掉 token 首尾引号(CreateProcess 不认字面引号路径)。"""
83
+ toks = shlex.split(str(cmd or ""), posix=False)
84
+ out = []
85
+ for t in toks:
86
+ if len(t) >= 2 and t[0] == t[-1] and t[0] in ('"', "'"):
87
+ t = t[1:-1]
88
+ if t:
89
+ out.append(t)
90
+ return out
91
+
92
+
93
+ def _run_one(h, payload):
94
+ """跑单条钩子。返回注入文本或 None(失败/超时/空输出)。"""
95
+ try:
96
+ argv = _argv(h.get("cmd"))
97
+ except ValueError as e:
98
+ log.warning("hook[%s %s] 命令解析失败:%s", h.get("name"), h.get("event"), str(e)[:120])
99
+ return None
100
+ if not argv:
101
+ return None
102
+ try:
103
+ proc = subprocess.run(
104
+ argv, shell=False,
105
+ input=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
106
+ capture_output=True, timeout=int(h.get("timeout_s") or _DEFAULT_TIMEOUT),
107
+ creationflags=_CREATE_NO_WINDOW)
108
+ except subprocess.TimeoutExpired:
109
+ log.warning("hook[%s %s] 超时,已跳过", h.get("name"), h.get("event"))
110
+ return None
111
+ except Exception as e:
112
+ log.warning("hook[%s %s] 执行失败:%s", h.get("name"), h.get("event"), str(e)[:120])
113
+ return None
114
+ out = (proc.stdout or b"").decode("utf-8", "replace").strip()
115
+ if not out:
116
+ return None
117
+ try:
118
+ data = json.loads(out)
119
+ if isinstance(data, dict):
120
+ return str(data.get("inject") or "") or None
121
+ except Exception:
122
+ pass
123
+ return out
124
+
125
+
126
+ def run_event(event, ctx=None, task_id=None, run_id=None, text=None):
127
+ """触发事件:依次跑启用的钩子,合并注入文本。任何异常不外抛。
128
+
129
+ ctx 附带任务/运行摘要;text 仅 message_submit 时传用户消息原文。
130
+ 返回拼接好的注入文本(可为空串)。"""
131
+ if event not in EVENTS:
132
+ return ""
133
+ try:
134
+ items = [h for h in load_list() if h.get("enabled") and h.get("event") == event]
135
+ except Exception:
136
+ return ""
137
+ if not items:
138
+ return ""
139
+ payload = {"event": event, "task_id": task_id or "", "run_id": run_id or "",
140
+ "text": text or ""}
141
+ if ctx is not None:
142
+ payload["ctx"] = ctx
143
+ outs = []
144
+ for h in items:
145
+ piece = _run_one(h, payload)
146
+ if piece:
147
+ outs.append(piece.strip())
148
+ return "\n\n".join(outs)
149
+
150
+
151
+ def test_one(cmd, event="task_start", timeout_s=None):
152
+ """设置页「测试」:手动跑一条钩子,返回 (ok, 输出/错误人话)。"""
153
+ h = {"name": "test", "event": event if event in EVENTS else "task_start",
154
+ "cmd": cmd, "timeout_s": timeout_s or _DEFAULT_TIMEOUT}
155
+ piece = _run_one(h, {"event": h["event"], "task_id": "", "run_id": "", "text": ""})
156
+ return True, piece if piece else "(无输出,未注入)"
@@ -19,7 +19,7 @@ import re
19
19
  import threading
20
20
  import time
21
21
 
22
- from . import aiflavor, attachments, catalog, dispatch_log, history, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage
22
+ from . import aiflavor, attachments, catalog, dispatch_log, history, hooks, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage, volumes
23
23
  from . import builtin_agent
24
24
  from . import diagnostics
25
25
  from . import paths as paths_mod
@@ -1539,6 +1539,14 @@ def _run_direct(run, task, agents, ev, stats, mode):
1539
1539
  # drain 的路径消息永远不消费,只看非空会空转到轮数上限。
1540
1540
  # 比较步骤前后的未消费数即可区分。
1541
1541
  before_n = len(_pending_messages(run_id))
1542
+ # 钩子注入(message_submit / task_start 通道):一次性消费拼提示词头
1543
+ hook_head = ""
1544
+ try:
1545
+ hook_head = store.pop_run_inject(run_id)
1546
+ except Exception:
1547
+ pass
1548
+ if hook_head:
1549
+ prompt = ("## 项目钩子注入\n" + hook_head + "\n\n" + prompt)
1542
1550
  if bi is not None:
1543
1551
  res = _run_builtin_step(run_id, "direct" if first else "chat", bi, prompt,
1544
1552
  step_wd, ev=ev, note=note, images=images,
@@ -1799,6 +1807,47 @@ def _plot_modules(workdir):
1799
1807
  "鼓励化用,不要照抄原句)\n\n" + txt)
1800
1808
 
1801
1809
 
1810
+ def _chain_volume_names(task):
1811
+ """沿 serial.continues 链收集各批次大纲里的卷名/卷弧光(旧 → 新,后者覆盖)。
1812
+ 续写批次合并成书时要给全书(含此前各批)的卷都写上标题,那些卷的命名记在
1813
+ 更早的批次任务上,本批 run 的 outline 里没有。失败静默返回 {}。"""
1814
+ from . import store as _store
1815
+ names, seen, cur = {}, set(), task
1816
+ chain = []
1817
+ while cur and len(chain) < 20:
1818
+ tid = str(cur.get("id") or "")
1819
+ if not tid or tid in seen:
1820
+ break
1821
+ seen.add(tid)
1822
+ chain.append(cur)
1823
+ nxt = str((cur.get("serial") or {}).get("continues") or "")
1824
+ cur = _store.get_task(nxt) if nxt else None
1825
+ for t in reversed(chain): # 旧 → 新:新批次的命名覆盖旧批次
1826
+ try:
1827
+ for r in _store.task_runs(t["id"]):
1828
+ o = r.get("outline") or {}
1829
+ if isinstance(o.get("volumes"), dict):
1830
+ names.update(o["volumes"])
1831
+ except Exception:
1832
+ continue
1833
+ return names
1834
+
1835
+
1836
+ def _book_volume_plan(task, outline, upto):
1837
+ """本书的卷规划表:显式卷表/每卷章数(serial)→ 规划表 → 合并卷名
1838
+ (本批大纲 + 沿链更早批次)→ 保证覆盖到第 upto 章。
1839
+ 不分卷返回 [];调用方据此决定是否插卷标题/卷末约束。"""
1840
+ serial = task.get("serial") or {}
1841
+ per = volumes.norm_per(serial.get("volume_chapters"))
1842
+ plan = volumes.build_plan(serial.get("volumes"), per, upto=upto)
1843
+ if not plan:
1844
+ return []
1845
+ named = dict(_chain_volume_names(task))
1846
+ if isinstance(outline, dict):
1847
+ named.update(outline.get("volumes") or {})
1848
+ return volumes.merge_titles(plan, named)
1849
+
1850
+
1802
1851
  LEDGER_FILE = os.path.join(".codebee", "resource-ledger.md")
1803
1852
  _LEDGER_MAX_CHARS = 6000 # 账本注入上限:太老的状态让评审官收敛 recent 优先
1804
1853
 
@@ -1913,6 +1962,7 @@ __OUTLINE__
1913
1962
 
1914
1963
  ---
1915
1964
  ## 本章任务(执行这一条即可)
1965
+ __VOLUME__
1916
1966
  - 撰写本书第 __I__ 章,把本章正文写入文件 `__FILE__`(直接写入该文件,只写本章)。文件必须以 UTF-8 编码保存:PowerShell 一律显式加 `-Encoding UTF8`(如 `Set-Content -Path __FILE__ -Encoding UTF8`),禁止依赖系统默认编码,否则中文会乱码。
1917
1967
  - 章节标题:__TITLE__
1918
1968
  - 剧情要点:__BEATS__
@@ -1932,6 +1982,8 @@ SERIAL_REVISE_PROMPT = """你是一名网文作者。第 __I__ 章没有通过
1932
1982
  ## 全书目标
1933
1983
  __GOAL__
1934
1984
 
1985
+ ## 本卷上下文
1986
+ __VOLUME__
1935
1987
  ## 本章评审意见
1936
1988
  __CRITIQUE__
1937
1989
 
@@ -2201,6 +2253,34 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2201
2253
  ("(章末钩子:%s)" % c.get("hook")) if c.get("hook") else "")
2202
2254
  for k, c in enumerate(outline["chapters"]))
2203
2255
 
2256
+ # 分卷规划表(本书级、由章号确定性推导):覆盖到本批末章即可。写成
2257
+ # 全书顶层字段,UI/报告/发布页直接读,不必重算。
2258
+ vol_plan = _book_volume_plan(task, outline, end)
2259
+ if vol_plan:
2260
+ store.update_run(run_id, volumes=vol_plan)
2261
+
2262
+ def vol_block_for(chapter):
2263
+ """单章的卷上下文:写作时知道自己身处哪一卷、卷内第几章、是否卷末。
2264
+ 不分卷返回 ""(提示词零噪音)。"""
2265
+ ent = volumes.find(vol_plan, chapter) if vol_plan else None
2266
+ if not ent:
2267
+ return ""
2268
+ pos, total = volumes.position(vol_plan, chapter)
2269
+ title = ("《%s》" % ent["title"]) if ent.get("title") else "(本卷待命名)"
2270
+ lines = ["- 本卷:第 %d 卷 %s" % (ent["vol"], title)]
2271
+ if total:
2272
+ lines.append("- 本卷位置:第 %d 章 / 共 %d 章" % (pos, total))
2273
+ else:
2274
+ lines.append("- 本卷位置:第 %d 章" % pos)
2275
+ if ent.get("arc"):
2276
+ lines.append("- 本卷弧光(本卷写作须服务这条主线):%s" % ent["arc"])
2277
+ if total and pos == total:
2278
+ lines.append("- **本章是卷末章**:必须收束本卷主线冲突,写出本卷最高潮,"
2279
+ "并在结尾留下牵出下一卷的大钩子(卷末是最重要的读者留存点)。")
2280
+ elif total and pos >= max(1, total - 1):
2281
+ lines.append("- 本章临近卷末:开始向本卷高潮收拢,不要再铺新的支线。")
2282
+ return "\n".join(lines) + "\n"
2283
+
2204
2284
  chapter_scores = [] # [{chapter,title,means,passed,rounds,words}]
2205
2285
  issues_all = []
2206
2286
 
@@ -2209,6 +2289,20 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2209
2289
  i = start + k - 1 # 全书章号:文件名/步骤角色/评分记录都按全书编号
2210
2290
  ch = outline["chapters"][k - 1]
2211
2291
  ch_file = "chapter-%02d.md" % i
2292
+ # 分卷上下文:评审时也带着「这是第几卷第几章、是不是卷末」——
2293
+ # 卷末章要按卷弧度收束,不符的章节不该靠全书评分才发现。
2294
+ _vpos, _vtotal = volumes.position(vol_plan, i) if vol_plan else (0, 0)
2295
+ vol_review_block = ""
2296
+ if vol_plan and _vpos:
2297
+ _vent = volumes.find(vol_plan, i)
2298
+ _vt = ("《%s》" % _vent["title"]) if (_vent and _vent.get("title")) else ""
2299
+ vol_review_block = (
2300
+ "本卷上下文:第 %d 卷%s,本章是卷内第 %d%s 章。%s\n"
2301
+ % (_vent["vol"], _vt, _vpos,
2302
+ ("/%d" % _vtotal) if _vtotal else "",
2303
+ "**卷末章**:须核对本卷主线是否收束、高潮是否到位、卷末钩子是否立住。"
2304
+ if (_vtotal and _vpos == _vtotal)
2305
+ else "核对本章是否服务于本卷主线弧光。"))
2212
2306
  # 逐项目标审稿(借鉴 AI-Novel-Writer):本章大纲要点随评审下发,评审
2213
2307
  # 以 <event_check> 回逐项判定;资源账本(借鉴角色资源账本)以 <ledger>
2214
2308
  # 回本章道具/伤情/承诺/伏笔增量,评审达标后追加账本文件供下章注入。
@@ -2220,8 +2314,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2220
2314
  "「铺垫了但没发生」不算已完成;未完成的项必须反映到对应维度评分。\n"
2221
2315
  "另在 <event_check> 块之后输出 <ledger> 块(没有新变化就整个省略):"
2222
2316
  "逐行列出本章新出现或状态变化的 道具/伤情/承诺/伏笔,格式:"
2223
- "类型|名称|现状(一句话)。\n\n"
2224
- % (ch.get("beats") or "按大纲推进", ch.get("hook") or "留下悬念"))
2317
+ "类型|名称|现状(一句话)。\n%s\n"
2318
+ % (ch.get("beats") or "按大纲推进", ch.get("hook") or "留下悬念",
2319
+ vol_review_block))
2225
2320
  ev_check_lines = [[]] # 每章重置:第一份非空评审的逐项判定
2226
2321
  ledger_lines = [] # 本章全部评审的账本增量并集
2227
2322
  ledger_txt = _read_ledger(workdir) # 截至上一章的资源账本(起草注入)
@@ -2384,6 +2479,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2384
2479
  return (SERIAL_CHAPTER_PROMPT
2385
2480
  .replace("__SKILLS__", sk_block)
2386
2481
  .replace("__SCOPE__", scope)
2482
+ .replace("__VOLUME__", vol_block_for(i))
2387
2483
  .replace("__I__", str(i)).replace("__FILE__", vfile)
2388
2484
  .replace("__GOAL__", task["goal"])
2389
2485
  .replace("__OUTLINE__", outline_txt)
@@ -2632,6 +2728,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2632
2728
  cs = dict(inh_scores[i])
2633
2729
  cs.setdefault("chapter", i)
2634
2730
  cs["reused"] = True
2731
+ _vent = volumes.find(vol_plan, i) if vol_plan else None
2732
+ if _vent:
2733
+ cs["vol"] = _vent["vol"]
2734
+ if _vent.get("title"):
2735
+ cs["vol_title"] = _vent["title"]
2635
2736
  chapter_scores.append(cs)
2636
2737
  store.update_run(run_id, chapter_scores=chapter_scores)
2637
2738
  continue
@@ -2695,6 +2796,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2695
2796
  prompt = (SERIAL_REVISE_PROMPT
2696
2797
  .replace("__I__", str(i)).replace("__FILE__", ch_file)
2697
2798
  .replace("__GOAL__", task["goal"])
2799
+ .replace("__VOLUME__", vol_block_for(i))
2698
2800
  .replace("__CRITIQUE__", "\n".join(crit_lines))
2699
2801
  .replace("__WORDS__", str(wpc)))
2700
2802
  prompt = attachments.append_task_context(prompt, task)
@@ -2710,6 +2812,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2710
2812
  "passed": bool(means) and all(v >= threshold_ch for v in means.values()),
2711
2813
  "rounds": rounds_used,
2712
2814
  "words": _wc(_read_chapter(workdir, i))}
2815
+ _vent = volumes.find(vol_plan, i) if vol_plan else None
2816
+ if _vent:
2817
+ cs_new["vol"] = _vent["vol"] # 章节卡按卷分组用
2818
+ if _vent.get("title"):
2819
+ cs_new["vol_title"] = _vent["title"]
2713
2820
  if ev_check_lines[0]:
2714
2821
  cs_new["event_check"] = ev_check_lines[0][:8] # 逐项目标审稿结果
2715
2822
  chapter_scores.append(cs_new)
@@ -2838,6 +2945,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2838
2945
  prompt = (SERIAL_REVISE_PROMPT
2839
2946
  .replace("__I__", str(i)).replace("__FILE__", "chapter-%02d.md" % i)
2840
2947
  .replace("__GOAL__", task["goal"])
2948
+ .replace("__VOLUME__", vol_block_for(i))
2841
2949
  .replace("__CRITIQUE__", crit)
2842
2950
  .replace("__WORDS__", str(wpc)))
2843
2951
  prompt = attachments.append_task_context(prompt, task)
@@ -2918,14 +3026,29 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2918
3026
  ms_name = _ms_name(task.get("manuscript"))
2919
3027
  book_title = outline.get("book_title") or task["title"]
2920
3028
  parts = ["# %s" % book_title, ""]
3029
+ # 分卷标题:卷首章之前插「第 X 卷 《卷名》」分隔(全书 1..end 都插,续写
3030
+ # 批次合并时上一批的卷标题也一并补上,不会只在首批出现)
3031
+ vol_heads = {}
3032
+ if vol_plan:
3033
+ for _ent in vol_plan:
3034
+ if _ent["first"] <= end:
3035
+ vol_heads[_ent["first"]] = _ent
2921
3036
  for i in range(1, end + 1): # 合并全书:续写时包含上一批已写好的章
3037
+ head = vol_heads.get(i)
3038
+ if head:
3039
+ vol_title = ("第 %d 卷 《%s》" % (head["vol"], head["title"])) \
3040
+ if head.get("title") else ("第 %d 卷" % head["vol"])
3041
+ parts.append("## %s" % vol_title)
3042
+ parts.append("")
2922
3043
  parts.append(_read_chapter(workdir, i).strip())
2923
3044
  parts.append("")
2924
3045
  with _ms_io(workdir, ms_name, "w") as f:
2925
3046
  f.write("\n".join(parts))
2926
3047
  total_words = _wc("\n".join(parts))
2927
3048
  store.finish_step(run_id, step["n"], "done",
2928
- summary="已合并 %d 章为 %s(约 %d 字)" % (n, ms_name, total_words),
3049
+ summary="已合并 %d 章为 %s(约 %d 字%s)" % (
3050
+ n, ms_name, total_words,
3051
+ ",分 %d 卷" % len(vol_heads) if vol_heads else ""),
2929
3052
  duration_s=0.1)
2930
3053
 
2931
3054
  chapters_pass = all(c["passed"] for c in chapter_scores)
@@ -2940,11 +3063,19 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2940
3063
  "chapter_scores": chapter_scores, "global_scores": global_means,
2941
3064
  "global_pass": global_pass, "route": route,
2942
3065
  }
3066
+ if vol_plan:
3067
+ verdict["volumes"] = vol_plan
2943
3068
  scope_txt = ("续写第 %d–%d 章,衔接前文 %d 章" % (start, end, start - 1)) if start > 1 \
2944
3069
  else ("共 %d 章" % n)
2945
3070
  lines = ["# 连载小说评审报告:%s" % task["title"], "",
2946
3071
  "- 书名:%s(%s / 约 %d 字,合并为 `%s`)" % (
2947
3072
  book_title, scope_txt, total_words, ms_name),
3073
+ "- 分卷:%s" % (";".join(
3074
+ ("第 %d 卷《%s》第 %d–%d 章" % (v["vol"], v.get("title") or "未命名",
3075
+ v["first"],
3076
+ v["last"] if v["last"] is not None else end))
3077
+ for v in vol_heads.values()) if vol_heads
3078
+ else ("未分卷(可设置「每卷章数」,或在目标里写明卷结构)")),
2948
3079
  "- 结论:**%s**(各章门禁 %s / 全局评审 %s)" % (
2949
3080
  "✅ 达到发布标准" if publishable else "❌ 未达标",
2950
3081
  "通过" if chapters_pass else "未通过",
@@ -3698,6 +3829,20 @@ def execute_run(run_id):
3698
3829
  })
3699
3830
  mode = task.get("mode") or ("manual" if task.get("implementer") else "auto")
3700
3831
  store.update_run(run_id, mode=mode)
3832
+ # 任务生命周期钩子(task_start,2026-09-22):stdout 注入文本并入任务上下文
3833
+ # ——context 在全引擎提示词都有 __CONTEXT__ 占位,一处并入全覆盖
3834
+ try:
3835
+ hook_txt = hooks.run_event("task_start",
3836
+ ctx={"title": task.get("title") or "",
3837
+ "type": task.get("type") or "",
3838
+ "mode": mode, "goal": task.get("goal") or ""},
3839
+ task_id=task.get("id") or "", run_id=run_id)
3840
+ if hook_txt:
3841
+ task["context"] = ((task.get("context") or "") + "\n\n" +
3842
+ t("## 项目钩子注入(task_start)\n") + hook_txt).strip()
3843
+ store.update_run(run_id, hook_inject=hook_txt[:400])
3844
+ except Exception:
3845
+ pass # 外部钩子故障绝不挡主流程
3701
3846
  # engine 决定流水线:code=实现/验证/评审/修复;review=起草/多维评审/修订/门禁;
3702
3847
  # direct=单 CLI 直达(无拆解/评审,信箱续轮即对话)
3703
3848
  engine = task.get("engine") or ("code" if task["type"] == "code" else "review")