codebee 0.1.103 → 0.1.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -21
  3. package/README.md +5 -3
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/acp_client.py +457 -457
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backend_profiles.py +188 -188
  13. package/app/core/backup.py +470 -470
  14. package/app/core/beekeeper.py +183 -183
  15. package/app/core/benchstore.py +102 -102
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/capability.py +144 -144
  20. package/app/core/chaptersafe.py +32 -32
  21. package/app/core/checkpoints.py +48 -5
  22. package/app/core/cleanup.py +303 -303
  23. package/app/core/command_auth.py +302 -302
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch_log.py +128 -128
  30. package/app/core/env_scrub.py +85 -85
  31. package/app/core/errorlog.py +137 -137
  32. package/app/core/eval_matrix.py +91 -91
  33. package/app/core/evaluation.py +174 -174
  34. package/app/core/failure_notify.py +290 -290
  35. package/app/core/flow_graph.py +33 -33
  36. package/app/core/goal_service.py +167 -167
  37. package/app/core/health.py +389 -389
  38. package/app/core/history.py +38 -38
  39. package/app/core/hooks.py +162 -162
  40. package/app/core/knowledge_pipeline.py +86 -86
  41. package/app/core/manager.py +2386 -2385
  42. package/app/core/mocks.py +64 -64
  43. package/app/core/notify.py +201 -201
  44. package/app/core/novel_quality.py +285 -181
  45. package/app/core/openapi.py +246 -217
  46. package/app/core/operations.py +228 -228
  47. package/app/core/paihang.py +145 -145
  48. package/app/core/pipeline.py +81 -11
  49. package/app/core/planner.py +7 -0
  50. package/app/core/portguard.py +111 -111
  51. package/app/core/presence.py +77 -77
  52. package/app/core/preview.py +293 -293
  53. package/app/core/project_memory.py +252 -206
  54. package/app/core/prompt_guard.py +78 -78
  55. package/app/core/publish/__init__.py +7 -7
  56. package/app/core/publish/auto.py +659 -659
  57. package/app/core/publish/chaprows.py +138 -138
  58. package/app/core/publish/flows-fanqie-calibrated.json +442 -442
  59. package/app/core/publish/manager.py +1185 -1185
  60. package/app/core/publish/qimao.py +193 -193
  61. package/app/core/publish/volumes.py +108 -108
  62. package/app/core/publish/ws.py +139 -139
  63. package/app/core/quality_gate.py +201 -201
  64. package/app/core/redact.py +38 -38
  65. package/app/core/registry.py +35 -0
  66. package/app/core/repeat_guard.py +129 -129
  67. package/app/core/retrieval.py +137 -137
  68. package/app/core/review_requests.py +119 -0
  69. package/app/core/runner.py +5 -1
  70. package/app/core/sandbox_runtime.py +95 -95
  71. package/app/core/selfupdate.py +384 -384
  72. package/app/core/session_log.py +161 -161
  73. package/app/core/sessions.py +312 -312
  74. package/app/core/settings.py +215 -215
  75. package/app/core/settings_schema.py +282 -282
  76. package/app/core/share_page.py +114 -114
  77. package/app/core/skill_scan.py +139 -139
  78. package/app/core/skillpacks/market/character-bible.md +66 -66
  79. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  80. package/app/core/skillpacks/market/git-workflow.md +57 -57
  81. package/app/core/skillpacks/market/release-notes.md +72 -72
  82. package/app/core/skillpacks/market/weekly-report.md +71 -71
  83. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  84. package/app/core/step_runner.py +132 -132
  85. package/app/core/story_tracking.py +275 -275
  86. package/app/core/task_compile.py +199 -199
  87. package/app/core/telemetry.py +292 -292
  88. package/app/core/tlsctx.py +64 -64
  89. package/app/core/token_meter.py +147 -147
  90. package/app/core/tool_outputs.py +107 -107
  91. package/app/core/tracing.py +207 -207
  92. package/app/core/upstream.py +41 -41
  93. package/app/core/usage.py +1038 -1038
  94. package/app/core/volumes.py +389 -389
  95. package/app/core/webhooks.py +89 -89
  96. package/app/core/wxdigest.py +865 -865
  97. package/app/core/zentao.py +2692 -2692
  98. package/app/main.py +43 -0
  99. package/app/mcp_server.py +244 -244
  100. package/app/pick_dialog.py +110 -110
  101. package/app/ui/app.js +21 -2
  102. package/app/ui/artdiff.js +74 -74
  103. package/app/ui/hive3d.js +54 -31
  104. package/app/ui/i18n.js +32 -1
  105. package/app/ui/icons/bee.svg +79 -79
  106. package/app/ui/index.html +1 -0
  107. package/app/ui/manifest.json +16 -16
  108. package/app/ui/qrcode.js +2297 -2297
  109. package/bin/tutti.js +147 -147
  110. package/package.json +1 -1
  111. package/tools/wx_reader.py +261 -261
  112. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  113. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  114. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  115. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  116. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  117. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  118. package/app/.playwright-cli/run-detail-after.png +0 -0
  119. package/app/output/_createbook_body.json +0 -1
  120. package/app/output/_probe_fq_click.py +0 -50
  121. package/app/output/_probe_fq_form.py +0 -42
  122. package/app/output/_probe_fq_modal.py +0 -55
  123. package/app/output/_probe_fq_points.py +0 -71
  124. package/app/output/_probe_fq_submit.py +0 -34
  125. package/app/output/_probe_fq_watch.py +0 -62
  126. package/app/output/workbench-clean-preview.jpg +0 -0
  127. package/app/output/workbench-reference-render.png +0 -0
  128. package/app/output/workbench-shell.png +0 -0
@@ -1,145 +1,145 @@
1
- # -*- coding: utf-8 -*-
2
- """扫榜选材:抓取公开排行榜页,产出选题分析素材。
3
-
4
- 数据源(四源聚合,任一失败不影响其余源;2026-09-24 实测全 200):
5
- - 七猫:www.qimao.com/paihang/(106KB)
6
- - 番茄 Web 版:fanqienovel.com/rank(题材分类+书名在中文 a 标签里;
7
- App 端接口有 SecuritySign/X-Argus 签名——那才是难路,Web 版榜单页
8
- 不需要签名,别走弯路)
9
- - 起点移动版:m.qidian.com/rank(106KB,直接出书名;主站 www.qidian.com
10
- 被 WAF 拦 202/209B,移动页不需要签名)
11
- - 纵横:www.zongheng.com/rank(65KB,书名+统计标签混排,靠噪音词过滤)
12
-
13
- 下载走 curl 子进程(与 covergen 同款),解析用宽松正则——页面结构变了
14
- 宁可返回空(调用方回落普通直连提示词),不做脆弱的强解析。
15
- """
16
- from __future__ import annotations
17
-
18
- import logging
19
- import re
20
-
21
- log = logging.getLogger(__name__)
22
-
23
- QIMAO_RANK_URL = "https://www.qimao.com/paihang/"
24
- FANQIE_RANK_URL = "https://fanqienovel.com/rank"
25
- QIDIAN_M_RANK_URL = "https://m.qidian.com/rank/"
26
- ZONGHENG_RANK_URL = "https://www.zongheng.com/rank/"
27
- _UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
28
- "(KHTML, like Gecko) Chrome/126 Safari/537.36")
29
-
30
- # 页面 UI 噪音词(非书目/分类):页面 chrome、导航、tab 名、登录/搜索、
31
- # 榜单 tab 与统计标签(起点移动版 tab 名、纵横月票/人气/点击等)
32
- _NOISE = {"加入书架", "立即阅读", "开始阅读", "免费阅读", "全部", "分类", "排行",
33
- "排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生",
34
- "帮助中心", "作家助手", "客服", "登录", "注册", "立即登录",
35
- "搜索", "热门作品排行", "大热榜", "新书榜", "完结榜", "收藏榜",
36
- "更新榜", "日榜", "月榜", "蝉联", "榜首", "热度", "免费", "畅销",
37
- "下载", "客户端", "首页", "我的", "设置", "更多", "返回", "关于",
38
- "月票榜", "阅读榜", "书友榜", "推荐榜", "签约榜", "新人榜",
39
- "畅销榜", "大家都在搜", "男生小说排行榜", "女生小说排行榜",
40
- "男频阅读榜", "女频阅读榜", "男频新书榜", "番茄小说",
41
- "登录后获得更多特色功能", "安装起点读书客户端", "看更多正版好书",
42
- "月票", "人气", "点击", "万字", "推荐", "收藏", "鲜花", "打赏", "推荐票"}
43
-
44
- _CJK_RE = re.compile(r">([\u4e00-\u9fa5]{2,12})<")
45
-
46
-
47
- def _curl_text(url):
48
- """curl 子进程抓页面文本;失败返回空串。"""
49
- from . import runner
50
- try:
51
- r = runner.run_process(
52
- argv=["curl", "-sS", "--max-time", "20", "-A", _UA, url],
53
- timeout=30)
54
- except Exception:
55
- return ""
56
- if not r.get("ok"):
57
- return ""
58
- return r.get("stdout") or ""
59
-
60
-
61
- def _extract_cjk(html, limit):
62
- """从 HTML 提取中文元素材(去 UI 噪音、保序去重)。"""
63
- items, seen = [], set()
64
- for t in _CJK_RE.findall(html or ""):
65
- if t in _NOISE or t in seen:
66
- continue
67
- seen.add(t)
68
- items.append(t)
69
- if len(items) >= limit:
70
- break
71
- return items
72
-
73
-
74
- def fetch_qimao_rank(limit=30):
75
- """七猫排行榜:返回元素列表,抓取失败返回 []。"""
76
- return _extract_cjk(_curl_text(QIMAO_RANK_URL), limit)
77
-
78
-
79
- def fetch_fanqie_rank(limit=30):
80
- """番茄 Web 版榜单:返回元素列表,抓取失败返回 []。"""
81
- return _extract_cjk(_curl_text(FANQIE_RANK_URL), limit)
82
-
83
-
84
- def fetch_qidian_rank(limit=30):
85
- """起点移动版榜单:返回元素列表,抓取失败返回 []。"""
86
- return _extract_cjk(_curl_text(QIDIAN_M_RANK_URL), limit)
87
-
88
-
89
- def fetch_zongheng_rank(limit=30):
90
- """纵横排行榜:返回元素列表,抓取失败返回 []。"""
91
- return _extract_cjk(_curl_text(ZONGHENG_RANK_URL), limit)
92
-
93
-
94
- # 源清单:顺序即素材拼接顺序(七猫/番茄为元老源保持在前)。
95
- # 存函数名、调用期 globals() 解析——mock.patch 换模块属性才能生效
96
- # (存函数引用会把定义期原函数钉死,测试会穿透打真网络)。
97
- _SOURCES = (
98
- ("七猫排行榜素材", "fetch_qimao_rank"),
99
- ("番茄排行榜素材", "fetch_fanqie_rank"),
100
- ("起点排行榜素材", "fetch_qidian_rank"),
101
- ("纵横排行榜素材", "fetch_zongheng_rank"),
102
- )
103
-
104
-
105
- def fetch_rank_items(limit=40):
106
- """四源聚合:各抓一份(跨源去重合并,按源清单顺序)。
107
-
108
- 全部失败才返回 []——单源挂了其余源仍可用,扫榜永不因单点挡任务。"""
109
- merged, seen = [], set()
110
- for _label, fname in _SOURCES:
111
- for t in globals()[fname](limit):
112
- if t not in seen:
113
- seen.add(t)
114
- merged.append(t)
115
- return merged
116
-
117
-
118
- def rank_scan_prompt(goal):
119
- """组装扫榜选材分析提示词。四源全空返回 None(调用方回落直连提示词)。
120
-
121
- 报告契约含逐条 A-E 跨平台证据分级(借鉴 career-ops 的分级报告交付形态):
122
- 分级只认素材实际出现情况——抑制单源孤证被模型放大成「大热题材」。"""
123
- blocks, seen = [], set()
124
- for label, fname in _SOURCES:
125
- items = [t for t in globals()[fname](30) if t not in seen]
126
- seen.update(items)
127
- if items:
128
- blocks.append("### %s\n%s" % (label, "、".join(items)))
129
- if not blocks:
130
- return None
131
- material = "\n\n".join(blocks)
132
- return (
133
- "你是网文选题分析师。以下是刚刚抓取的各平台排行榜页面的书目与分类素材:\n\n"
134
- "## 榜单元素材\n%s\n\n"
135
- "## 用户想写的方向\n%s\n\n"
136
- "## 你的产出(Markdown 报告)\n"
137
- "报告第一行给图例(一行):「证据分级:A=三平台及以上重合,B=两平台重合,"
138
- "C=单平台多次出现,D=单平台仅一次,E=素材无直接证据、系推断」。\n"
139
- "1. **热门题材 Top3**:各自的共同特征与上榜代表书目(跨平台重合的题材单独点出——多平台都热说明是真风口)\n"
140
- "2. **高频人设/套路总结**:3-5 条,点名反复出现的元素\n"
141
- "3. **差异化切入建议**:2-3 个,结合用户方向给出「题材+人设」组合与一句话理由\n"
142
- "以上每一项结论末尾标注【证据X】(X 为 A-E 之一)。分级只看素材里真实出现的"
143
- "情况:组合条目按其组成元素的较低档取值,拿不准就降一档、不升级。\n"
144
- "直接输出分析报告,不要复述素材清单,不要输出与报告无关的内容。"
145
- % (material, goal or "(用户未指定方向,按大盘热门分析)"))
1
+ # -*- coding: utf-8 -*-
2
+ """扫榜选材:抓取公开排行榜页,产出选题分析素材。
3
+
4
+ 数据源(四源聚合,任一失败不影响其余源;2026-09-24 实测全 200):
5
+ - 七猫:www.qimao.com/paihang/(106KB)
6
+ - 番茄 Web 版:fanqienovel.com/rank(题材分类+书名在中文 a 标签里;
7
+ App 端接口有 SecuritySign/X-Argus 签名——那才是难路,Web 版榜单页
8
+ 不需要签名,别走弯路)
9
+ - 起点移动版:m.qidian.com/rank(106KB,直接出书名;主站 www.qidian.com
10
+ 被 WAF 拦 202/209B,移动页不需要签名)
11
+ - 纵横:www.zongheng.com/rank(65KB,书名+统计标签混排,靠噪音词过滤)
12
+
13
+ 下载走 curl 子进程(与 covergen 同款),解析用宽松正则——页面结构变了
14
+ 宁可返回空(调用方回落普通直连提示词),不做脆弱的强解析。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import re
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ QIMAO_RANK_URL = "https://www.qimao.com/paihang/"
24
+ FANQIE_RANK_URL = "https://fanqienovel.com/rank"
25
+ QIDIAN_M_RANK_URL = "https://m.qidian.com/rank/"
26
+ ZONGHENG_RANK_URL = "https://www.zongheng.com/rank/"
27
+ _UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
28
+ "(KHTML, like Gecko) Chrome/126 Safari/537.36")
29
+
30
+ # 页面 UI 噪音词(非书目/分类):页面 chrome、导航、tab 名、登录/搜索、
31
+ # 榜单 tab 与统计标签(起点移动版 tab 名、纵横月票/人气/点击等)
32
+ _NOISE = {"加入书架", "立即阅读", "开始阅读", "免费阅读", "全部", "分类", "排行",
33
+ "排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生",
34
+ "帮助中心", "作家助手", "客服", "登录", "注册", "立即登录",
35
+ "搜索", "热门作品排行", "大热榜", "新书榜", "完结榜", "收藏榜",
36
+ "更新榜", "日榜", "月榜", "蝉联", "榜首", "热度", "免费", "畅销",
37
+ "下载", "客户端", "首页", "我的", "设置", "更多", "返回", "关于",
38
+ "月票榜", "阅读榜", "书友榜", "推荐榜", "签约榜", "新人榜",
39
+ "畅销榜", "大家都在搜", "男生小说排行榜", "女生小说排行榜",
40
+ "男频阅读榜", "女频阅读榜", "男频新书榜", "番茄小说",
41
+ "登录后获得更多特色功能", "安装起点读书客户端", "看更多正版好书",
42
+ "月票", "人气", "点击", "万字", "推荐", "收藏", "鲜花", "打赏", "推荐票"}
43
+
44
+ _CJK_RE = re.compile(r">([\u4e00-\u9fa5]{2,12})<")
45
+
46
+
47
+ def _curl_text(url):
48
+ """curl 子进程抓页面文本;失败返回空串。"""
49
+ from . import runner
50
+ try:
51
+ r = runner.run_process(
52
+ argv=["curl", "-sS", "--max-time", "20", "-A", _UA, url],
53
+ timeout=30)
54
+ except Exception:
55
+ return ""
56
+ if not r.get("ok"):
57
+ return ""
58
+ return r.get("stdout") or ""
59
+
60
+
61
+ def _extract_cjk(html, limit):
62
+ """从 HTML 提取中文元素材(去 UI 噪音、保序去重)。"""
63
+ items, seen = [], set()
64
+ for t in _CJK_RE.findall(html or ""):
65
+ if t in _NOISE or t in seen:
66
+ continue
67
+ seen.add(t)
68
+ items.append(t)
69
+ if len(items) >= limit:
70
+ break
71
+ return items
72
+
73
+
74
+ def fetch_qimao_rank(limit=30):
75
+ """七猫排行榜:返回元素列表,抓取失败返回 []。"""
76
+ return _extract_cjk(_curl_text(QIMAO_RANK_URL), limit)
77
+
78
+
79
+ def fetch_fanqie_rank(limit=30):
80
+ """番茄 Web 版榜单:返回元素列表,抓取失败返回 []。"""
81
+ return _extract_cjk(_curl_text(FANQIE_RANK_URL), limit)
82
+
83
+
84
+ def fetch_qidian_rank(limit=30):
85
+ """起点移动版榜单:返回元素列表,抓取失败返回 []。"""
86
+ return _extract_cjk(_curl_text(QIDIAN_M_RANK_URL), limit)
87
+
88
+
89
+ def fetch_zongheng_rank(limit=30):
90
+ """纵横排行榜:返回元素列表,抓取失败返回 []。"""
91
+ return _extract_cjk(_curl_text(ZONGHENG_RANK_URL), limit)
92
+
93
+
94
+ # 源清单:顺序即素材拼接顺序(七猫/番茄为元老源保持在前)。
95
+ # 存函数名、调用期 globals() 解析——mock.patch 换模块属性才能生效
96
+ # (存函数引用会把定义期原函数钉死,测试会穿透打真网络)。
97
+ _SOURCES = (
98
+ ("七猫排行榜素材", "fetch_qimao_rank"),
99
+ ("番茄排行榜素材", "fetch_fanqie_rank"),
100
+ ("起点排行榜素材", "fetch_qidian_rank"),
101
+ ("纵横排行榜素材", "fetch_zongheng_rank"),
102
+ )
103
+
104
+
105
+ def fetch_rank_items(limit=40):
106
+ """四源聚合:各抓一份(跨源去重合并,按源清单顺序)。
107
+
108
+ 全部失败才返回 []——单源挂了其余源仍可用,扫榜永不因单点挡任务。"""
109
+ merged, seen = [], set()
110
+ for _label, fname in _SOURCES:
111
+ for t in globals()[fname](limit):
112
+ if t not in seen:
113
+ seen.add(t)
114
+ merged.append(t)
115
+ return merged
116
+
117
+
118
+ def rank_scan_prompt(goal):
119
+ """组装扫榜选材分析提示词。四源全空返回 None(调用方回落直连提示词)。
120
+
121
+ 报告契约含逐条 A-E 跨平台证据分级(借鉴 career-ops 的分级报告交付形态):
122
+ 分级只认素材实际出现情况——抑制单源孤证被模型放大成「大热题材」。"""
123
+ blocks, seen = [], set()
124
+ for label, fname in _SOURCES:
125
+ items = [t for t in globals()[fname](30) if t not in seen]
126
+ seen.update(items)
127
+ if items:
128
+ blocks.append("### %s\n%s" % (label, "、".join(items)))
129
+ if not blocks:
130
+ return None
131
+ material = "\n\n".join(blocks)
132
+ return (
133
+ "你是网文选题分析师。以下是刚刚抓取的各平台排行榜页面的书目与分类素材:\n\n"
134
+ "## 榜单元素材\n%s\n\n"
135
+ "## 用户想写的方向\n%s\n\n"
136
+ "## 你的产出(Markdown 报告)\n"
137
+ "报告第一行给图例(一行):「证据分级:A=三平台及以上重合,B=两平台重合,"
138
+ "C=单平台多次出现,D=单平台仅一次,E=素材无直接证据、系推断」。\n"
139
+ "1. **热门题材 Top3**:各自的共同特征与上榜代表书目(跨平台重合的题材单独点出——多平台都热说明是真风口)\n"
140
+ "2. **高频人设/套路总结**:3-5 条,点名反复出现的元素\n"
141
+ "3. **差异化切入建议**:2-3 个,结合用户方向给出「题材+人设」组合与一句话理由\n"
142
+ "以上每一项结论末尾标注【证据X】(X 为 A-E 之一)。分级只看素材里真实出现的"
143
+ "情况:组合条目按其组成元素的较低档取值,拿不准就降一档、不升级。\n"
144
+ "直接输出分析报告,不要复述素材清单,不要输出与报告无关的内容。"
145
+ % (material, goal or "(用户未指定方向,按大盘热门分析)"))
@@ -445,11 +445,20 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
445
445
  model=agent.get("model") or "",
446
446
  provider=_binding_brief(agent))
447
447
  try:
448
+ checkpoint_effects = [] if readonly else ["workspace_files"]
449
+ if agent.get("mode") != "mock":
450
+ checkpoint_effects.append("external_agent")
451
+ if agent.get("kind") in ("acp", "mcp"):
452
+ checkpoint_effects.append("external_protocol")
448
453
  checkpoints.start(
449
454
  run_id, step["n"], role, prompt,
450
455
  input_data={"workdir": workdir, "readonly": readonly,
451
456
  "agent": agent.get("id")},
452
- attempt=len((store.get_run(run_id) or {}).get("steps") or []))
457
+ attempt=len((store.get_run(run_id) or {}).get("steps") or []),
458
+ tool_call={"name": "agent_step", "agent": agent.get("id"),
459
+ "role": role, "readonly": bool(readonly)},
460
+ conversation_ref=resume or "",
461
+ side_effects=checkpoint_effects)
453
462
  except Exception:
454
463
  pass
455
464
  start = time.time()
@@ -475,7 +484,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
475
484
  "tokens": 0, "usage": None, "error": sandbox_error,
476
485
  "raw": {"exit_code": None, "sandbox_blocked": True},
477
486
  "kind": agent.get("kind", "generic"), "model": agent.get("model")}
478
- _finish_step_result(run_id, step, res, role, agent, start)
487
+ _finish_step_result(run_id, step, res, role, agent, start,
488
+ readonly=readonly)
479
489
  return res
480
490
  if dead_binding:
481
491
  from .error_codes import ErrorCode
@@ -484,7 +494,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
484
494
  "error_code": ErrorCode.ENV_BLOCK,
485
495
  "raw": {"exit_code": None}, "kind": agent.get("kind", "generic"),
486
496
  "model": agent.get("model")}
487
- _finish_step_result(run_id, step, res, role, agent, start)
497
+ _finish_step_result(run_id, step, res, role, agent, start,
498
+ readonly=readonly)
488
499
  return res
489
500
  if agent.get("mode") == "mock":
490
501
  time.sleep(0.3)
@@ -507,7 +518,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
507
518
  "raw": {"exit_code": None, "repeat_stop": True},
508
519
  "kind": agent.get("kind", "generic"),
509
520
  "model": agent.get("model")}
510
- _finish_step_result(run_id, step, res, role, agent, start)
521
+ _finish_step_result(run_id, step, res, role, agent, start,
522
+ readonly=readonly)
511
523
  return res
512
524
  # 运行中指挥:drain 用户追加的指令/附件,注入本步(守门拦截时不 drain,
513
525
  # 消息留给下一个真实步骤,不空耗);role/step_n 作送达回执
@@ -529,7 +541,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
529
541
  "error": git_checkpoint.get("error") or "文件检查点准备失败",
530
542
  "raw": {"exit_code": None, "checkpoint_blocked": True},
531
543
  "kind": agent.get("kind", "generic"), "model": agent.get("model")}
532
- _finish_step_result(run_id, step, res, role, agent, start)
544
+ _finish_step_result(run_id, step, res, role, agent, start,
545
+ readonly=readonly)
533
546
  return res
534
547
  res = _spawn_step(session_run_id=run_id, role=role, agent=agent,
535
548
  prompt=effective_prompt, workdir=workdir, readonly=readonly,
@@ -561,7 +574,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
561
574
  pass
562
575
  # 先收尾再查取消:取消时进程已被 run_process 杀停,若先抛 Cancelled,
563
576
  # 步骤记录会永远停在「运行中」变僵尸(与 _run_verify 的顺序对齐)
564
- _finish_step_result(run_id, step, res, role, agent, start)
577
+ _finish_step_result(run_id, step, res, role, agent, start,
578
+ readonly=readonly)
565
579
  if (res.get("raw") or {}).get("deadline_exceeded"):
566
580
  store.update_run(run_id, expected_status="running", status="timeout",
567
581
  ended_at=_now(), error="任务总时限已到")
@@ -883,7 +897,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
883
897
  return res
884
898
 
885
899
 
886
- def _finish_step_result(run_id, step, res, role, agent, start):
900
+ def _finish_step_result(run_id, step, res, role, agent, start, *, readonly=None):
887
901
  """step 收尾:记录 + 用量 + 运行时断言(5F)。"""
888
902
  # 被取消杀停的步骤如实记「已取消」;超时被杀记「超时」——都只是步骤
889
903
  # 显示层的细分,run 级仍是 failed,善后路径(重试/自动续跑)不变
@@ -924,7 +938,10 @@ def _finish_step_result(run_id, step, res, role, agent, start):
924
938
  checkpoints.finish(
925
939
  run_id, step["n"], checkpoint_status,
926
940
  error=res.get("error") or "", output=res.get("text") or "",
927
- metadata={"role": role, "agent": agent.get("id") or ""})
941
+ metadata={"role": role, "agent": agent.get("id") or "",
942
+ "repropose_required": True,
943
+ "side_effects": ([] if readonly is True else ["workspace_files"]) +
944
+ (["external_agent"] if agent.get("mode") != "mock" else [])})
928
945
  except Exception:
929
946
  pass
930
947
  # 错误台账:失败/超时各记一条结构化记录(遥测与诊断包的数据源)。
@@ -1040,6 +1057,7 @@ __FILES__
1040
1057
  __CONTEXT__
1041
1058
 
1042
1059
  ## 要求
1060
+ - 思考先行:动手前先读懂相关代码、明确验收标准再改;不读懂就动手是大改与返工的主因。
1043
1061
  - 修改要最小化、聚焦任务目标;不要无关重构。
1044
1062
  - 反过度防御(借鉴 HERO:只约束你「提出」的方案,不约束你「检查」的范围)——
1045
1063
  不要给没要求的数据加哈希/校验和;不要为内部脚本铺层层边界分支;
@@ -2919,6 +2937,7 @@ __VOLUME__
2919
2937
  __LOCKED_CH__
2920
2938
  __STALE_NOTE__
2921
2939
  __REDO_ISSUES__
2940
+ __REUSE_GUARD__
2922
2941
  - 正文约 __WORDS__ 字,中文,直接开写正文(可含本章标题行)。
2923
2942
 
2924
2943
  ## 前情提要(此前各章结尾摘录,衔接用)
@@ -3085,6 +3104,33 @@ def _wc(text):
3085
3104
  return len(re.sub(r"\s", "", text or ""))
3086
3105
 
3087
3106
 
3107
+ def _reuse_guard_samples(workdir, upto, window=4, min_words=200):
3108
+ """收集此前各章的开笔/收束摘录(起草负样例,防跨章句式同款——105-112 批
3109
+ 「落笔入册」收束 8 章用 4 次被判 major 的根治手段之一:评审事后抓 → 起草
3110
+ 事前给反面样例)。只看近 window 章(更早的句式影响衰减);过短/读不到的
3111
+ 章静默跳过。"""
3112
+ samples = []
3113
+ for j in range(max(1, int(upto) - int(window)), int(upto)):
3114
+ t = _read_chapter(workdir, j)
3115
+ if not t or _wc(t) < int(min_words):
3116
+ continue
3117
+ body = re.sub(r"\s+", " ", t.strip())
3118
+ samples.append({"chapter": j, "head": body[:80], "tail": body[-100:]})
3119
+ return samples
3120
+
3121
+
3122
+ def _dup_reject_note(notes):
3123
+ """查重打回后的重写指令块:把上一稿被拦截的重复证据逐条下发。"""
3124
+ lines = novel_quality.format_duplicate_notes(notes)
3125
+ if not lines:
3126
+ return ""
3127
+ return ("\n\n## 确定性查重拦截(必读)\n"
3128
+ "上一稿因整段/整句逐字重复未通过落盘验收,已被打回重写。重写时必须"
3129
+ "消除以下重复——删并重复内容、保留信息量最高的一处;若为有意的复诵"
3130
+ "或复沓,必须有动作/归属铺垫并改写措辞,不得逐字再现:\n- "
3131
+ + "\n- ".join(lines) + "\n")
3132
+
3133
+
3088
3134
  _ANCHOR_EDGE_PUNCT = "。!?,、;:…—·,.!?;:\"'“”‘’()()[]【】《》<>*#-"
3089
3135
 
3090
3136
 
@@ -3901,6 +3947,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3901
3947
  redo_lines += _major_lines(book_lvl)
3902
3948
  redo_note = ("## 上一轮评审未达标原因(必读)\n" + "\n".join(redo_lines) + "\n"
3903
3949
  ) if redo_lines else ""
3950
+ # 跨章复用红线负样例:此前各章实际的开笔/收束摘录随起草下发,把
3951
+ # 「评审事后抓句式同款」前移成「起草事前看反面样例」(105-112 批
3952
+ # 章末收束句式 4 连用被判 major 的根治;首批第 1 章无样例为空块)
3953
+ reuse_guard = novel_quality.reuse_guard_block(
3954
+ _reuse_guard_samples(workdir, i))
3904
3955
  # stable_order:同一任务 8 个章节的技能块必须字节级一致(§07 T1.2' 前缀缓存)
3905
3956
  # 三块分开留底:起草重试的分层降级(_serial_shrunk_block)要按
3906
3957
  # 「经验库/圣经」各自的身份收缩,折成一整块就只剩 4K 硬截一层了
@@ -3940,6 +3991,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3940
3991
  .replace("__LOCKED_CH__", serial_locked_chapter_block(workdir, i))
3941
3992
  .replace("__STALE_NOTE__", stale_note)
3942
3993
  .replace("__REDO_ISSUES__", redo_note)
3994
+ .replace("__REUSE_GUARD__", reuse_guard)
3943
3995
  .replace("__REVIEW_RUBRIC__", rubric_block)
3944
3996
  .replace("__HIGHLIGHT__", ch.get("highlight") or "按剧情要点自然铺设一处小冲突/小反转")
3945
3997
  .replace("__STYLE__", style_tpl)
@@ -3956,9 +4008,14 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3956
4008
  # ——2026-09-17 七猫连载实测:多 run 并行推进时后位章节必撞限流,
3957
4009
  # 一章失败即整 run 作废,太贵。
3958
4010
  def _chapter_state():
3959
- """草稿验收:成品是章稿文件本身,不是退出码——文件够长才算好。"""
4011
+ """草稿验收:成品是章稿文件本身,不是退出码——文件够长且无
4012
+ 逐字重复才算好。整段/整句/完整对白逐字重复是成稿事故(113/
4013
+ 118 章实案两处整段连排、对白复现,评审 major 一票否决+白烧
4014
+ 一轮评审),落盘时确定性拦截打回,省下评审再抓的往返。"""
3960
4015
  txt = _read_chapter(workdir, i)
3961
- return (bool(txt) and _wc(txt) >= int(wpc * 0.6)), (txt or "")
4016
+ if not txt or _wc(txt) < int(wpc * 0.6):
4017
+ return False, (txt or "")
4018
+ return not novel_quality.duplicate_notes(txt), txt
3962
4019
 
3963
4020
  res = None
3964
4021
  good = False
@@ -3971,6 +4028,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3971
4028
  prev_prompt = None # 上一次实际下发的提示词
3972
4029
  prev_timed_out = False # 上一次是否「超时/停滞」收场
3973
4030
  shrink_note = "" # 本次重试的分层降级说明(进 step note 供对账)
4031
+ dup_fix_note = "" # 查重打回后的消重指令(重试/换将随提示词下发)
3974
4032
  for draft_attempt in range(3):
3975
4033
  if draft_attempt:
3976
4034
  # 30s / 60s 退避;ev.wait 睡等可被取消即刻唤醒
@@ -3986,7 +4044,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3986
4044
  # 30KB 对讯飞必挂)
3987
4045
  shrunk_block, shrink_note = _serial_shrunk_block(
3988
4046
  sk_lessons, bible, kb_block)
3989
- use_prompt = prompt.replace(sk_block, shrunk_block)
4047
+ use_prompt = prompt.replace(sk_block, shrunk_block) + dup_fix_note
4048
+ elif draft_attempt and dup_fix_note:
4049
+ # 同作者重试带上一稿的重复证据(消重指令)——同提示重跑
4050
+ # 只会长出同族事故,新变量才值得烧(同 2026-09-22 超时教训)
4051
+ use_prompt = prompt + dup_fix_note
3990
4052
  # 超时重试必须有新变量才值得做(2026-09-22):提示词与上次逐字节
3991
4053
  # 相同、上次又是超时/停滞收场时,重试只是把同一个超时再烧一遍
3992
4054
  # (2400s × N)。此时直接跳出交替换将——换 CLI/模型才是新机会。
@@ -4014,6 +4076,10 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4014
4076
  good, txt = _chapter_state()
4015
4077
  if good:
4016
4078
  break
4079
+ if txt:
4080
+ _dups = novel_quality.duplicate_notes(txt)
4081
+ if _dups:
4082
+ dup_fix_note = _dup_reject_note(_dups)
4017
4083
  if res["ok"] and _wc(res.get("text") or "") >= int(wpc * 0.6):
4018
4084
  # 回复正文就是完整章稿(kimi 35B 实测:正文当消息回而不
4019
4085
  # 落盘)→ 代为落盘救回成品
@@ -4024,6 +4090,10 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4024
4090
  pass
4025
4091
  if good:
4026
4092
  break
4093
+ if txt:
4094
+ _dups = novel_quality.duplicate_notes(txt)
4095
+ if _dups:
4096
+ dup_fix_note = _dup_reject_note(_dups)
4027
4097
  if ev is not None and ev.is_set():
4028
4098
  break
4029
4099
  # 同作者重试穷尽 → 起草换将:按路由分序逐个试备选(最多 2 个,
@@ -266,6 +266,10 @@ __SKILLS__
266
266
  不强制每章都打脸、反转或以悬念断章;
267
267
  - 章末可自然收束或留白;大纲中的 highlight 描述主要戏剧/情绪变化或回报,无显著爆点时写清叙事功能,不要强造爆点。
268
268
  - 大纲中的情感转折必须给出触发事件和人物选择,不能只写“误会加深/感情升温”;
269
+ - 开笔方式与收束形态批内轮换:相邻章不得用同型句子开笔(如连续多章以「第X日/
270
+ 清晨」时间戳起笔),收束形态(落笔定格、悬置问句、场景切换、对话收拍、动作
271
+ 停顿等)同一形态全批至多两次,第二次必须换载体或升级信息量——章尾节奏趋同
272
+ 是批量生成最易露馅的指纹;
269
273
  - style_anchor 是全书唯一的文风锚,每章起草/修订/评审都会注入:必须写明视角人称与句长基调等可执行约束,不要空话;
270
274
  - highlight 是本章主要情绪/戏剧变化或读者回报,可为爽、暖、痛、惊、趣味、亲密、发现、安定等;
271
275
  平静或过渡章节可承担必要功能,不为填字段伪造高潮;
@@ -293,6 +297,9 @@ __SKILLS__
293
297
  - 在关键章节写清人物选择、阻碍与后果;不要求每章都有完整冲突闭环或悬念钩子,避免无功能重复;
294
298
  - 复杂情感必须落到触发事件、动作/生理反应和潜台词,不能用一句概括代替;
295
299
  - 延续既定叙述视角和语言锚点(见下方既定文风锚),删掉重复复述,避免节奏变慢;
300
+ - 开笔方式与收束形态批内轮换:相邻章不得用同型句子开笔(如连续多章以「第X日/
301
+ 清晨」时间戳起笔),收束形态同一形态全批至多两次,第二次必须换载体或升级
302
+ 信息量——章尾节奏趋同是批量生成最易露馅的指纹;
296
303
  - 题材健康,无违规内容,符合平台签约调性。
297
304
  __VOLUMES__
298
305