@chinhae/atw-cli 0.5.0 → 0.7.0

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 (257) hide show
  1. package/README.md +128 -451
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +24 -1
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/commands/channel/adapters/claude.d.ts +44 -2
  6. package/dist/commands/channel/adapters/claude.d.ts.map +1 -1
  7. package/dist/commands/channel/adapters/claude.js +124 -23
  8. package/dist/commands/channel/adapters/claude.js.map +1 -1
  9. package/dist/commands/channel/adapters/codex.d.ts +36 -6
  10. package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
  11. package/dist/commands/channel/adapters/codex.js +150 -10
  12. package/dist/commands/channel/adapters/codex.js.map +1 -1
  13. package/dist/commands/channel/adapters/index.d.ts +33 -4
  14. package/dist/commands/channel/adapters/index.d.ts.map +1 -1
  15. package/dist/commands/channel/adapters/index.js +159 -6
  16. package/dist/commands/channel/adapters/index.js.map +1 -1
  17. package/dist/commands/channel/adapters/omp.d.ts +106 -0
  18. package/dist/commands/channel/adapters/omp.d.ts.map +1 -0
  19. package/dist/commands/channel/adapters/omp.js +405 -0
  20. package/dist/commands/channel/adapters/omp.js.map +1 -0
  21. package/dist/commands/channel/adapters/pi.d.ts +123 -0
  22. package/dist/commands/channel/adapters/pi.d.ts.map +1 -0
  23. package/dist/commands/channel/adapters/pi.js +387 -0
  24. package/dist/commands/channel/adapters/pi.js.map +1 -0
  25. package/dist/commands/channel/adapters/types.d.ts +16 -0
  26. package/dist/commands/channel/adapters/types.d.ts.map +1 -1
  27. package/dist/commands/channel/agent-loader.d.ts +1 -0
  28. package/dist/commands/channel/agent-loader.d.ts.map +1 -1
  29. package/dist/commands/channel/agent-loader.js +45 -5
  30. package/dist/commands/channel/agent-loader.js.map +1 -1
  31. package/dist/commands/channel/dev-parse-trace.d.ts +1 -1
  32. package/dist/commands/channel/dev-parse-trace.d.ts.map +1 -1
  33. package/dist/commands/channel/dev-parse-trace.js +25 -2
  34. package/dist/commands/channel/dev-parse-trace.js.map +1 -1
  35. package/dist/commands/channel/index.d.ts.map +1 -1
  36. package/dist/commands/channel/index.js +44 -12
  37. package/dist/commands/channel/index.js.map +1 -1
  38. package/dist/commands/channel/run.d.ts +39 -2
  39. package/dist/commands/channel/run.d.ts.map +1 -1
  40. package/dist/commands/channel/run.js +227 -68
  41. package/dist/commands/channel/run.js.map +1 -1
  42. package/dist/commands/channel/spawn.d.ts +7 -2
  43. package/dist/commands/channel/spawn.d.ts.map +1 -1
  44. package/dist/commands/channel/spawn.js +35 -1
  45. package/dist/commands/channel/spawn.js.map +1 -1
  46. package/dist/commands/channel/store/events.d.ts +2 -2
  47. package/dist/commands/channel/store/events.d.ts.map +1 -1
  48. package/dist/commands/channel/store/events.js +1 -1
  49. package/dist/commands/channel/store/events.js.map +1 -1
  50. package/dist/commands/channel/supervisor/shutdown.d.ts +2 -0
  51. package/dist/commands/channel/supervisor/shutdown.d.ts.map +1 -1
  52. package/dist/commands/channel/supervisor/shutdown.js +6 -1
  53. package/dist/commands/channel/supervisor/shutdown.js.map +1 -1
  54. package/dist/commands/channel/supervisor.d.ts +2 -2
  55. package/dist/commands/channel/supervisor.d.ts.map +1 -1
  56. package/dist/commands/channel/supervisor.js +26 -3
  57. package/dist/commands/channel/supervisor.js.map +1 -1
  58. package/dist/commands/init.d.ts.map +1 -1
  59. package/dist/commands/init.js +6 -1
  60. package/dist/commands/init.js.map +1 -1
  61. package/dist/commands/mem.d.ts +2 -1
  62. package/dist/commands/mem.d.ts.map +1 -1
  63. package/dist/commands/mem.js +162 -13
  64. package/dist/commands/mem.js.map +1 -1
  65. package/dist/commands/mode.d.ts.map +1 -1
  66. package/dist/commands/mode.js +5 -3
  67. package/dist/commands/mode.js.map +1 -1
  68. package/dist/commands/uninstall.d.ts +5 -3
  69. package/dist/commands/uninstall.d.ts.map +1 -1
  70. package/dist/commands/uninstall.js +40 -16
  71. package/dist/commands/uninstall.js.map +1 -1
  72. package/dist/commands/update.d.ts +34 -0
  73. package/dist/commands/update.d.ts.map +1 -1
  74. package/dist/commands/update.js +209 -11
  75. package/dist/commands/update.js.map +1 -1
  76. package/dist/commands/verify.d.ts +18 -0
  77. package/dist/commands/verify.d.ts.map +1 -0
  78. package/dist/commands/verify.js +75 -0
  79. package/dist/commands/verify.js.map +1 -0
  80. package/dist/configurators/index.d.ts.map +1 -1
  81. package/dist/configurators/index.js +3 -2
  82. package/dist/configurators/index.js.map +1 -1
  83. package/dist/configurators/omp.d.ts +22 -0
  84. package/dist/configurators/omp.d.ts.map +1 -1
  85. package/dist/configurators/omp.js +101 -2
  86. package/dist/configurators/omp.js.map +1 -1
  87. package/dist/configurators/shared.d.ts.map +1 -1
  88. package/dist/configurators/shared.js +39 -7
  89. package/dist/configurators/shared.js.map +1 -1
  90. package/dist/configurators/workflow.d.ts.map +1 -1
  91. package/dist/configurators/workflow.js +5 -1
  92. package/dist/configurators/workflow.js.map +1 -1
  93. package/dist/constants/paths.d.ts +4 -0
  94. package/dist/constants/paths.d.ts.map +1 -1
  95. package/dist/constants/paths.js +4 -0
  96. package/dist/constants/paths.js.map +1 -1
  97. package/dist/migrations/manifests/0.6.0.json +442 -0
  98. package/dist/migrations/manifests/0.7.0.json +1378 -0
  99. package/dist/templates/atw/definition-of-done.md +65 -0
  100. package/dist/templates/atw/gitignore.txt +7 -0
  101. package/dist/templates/atw/index.d.ts +11 -0
  102. package/dist/templates/atw/index.d.ts.map +1 -1
  103. package/dist/templates/atw/index.js +22 -0
  104. package/dist/templates/atw/index.js.map +1 -1
  105. package/dist/templates/atw/scripts/common/active_task.py +2 -2
  106. package/dist/templates/atw/scripts/common/workflow_phase.py +33 -0
  107. package/dist/templates/atw/scripts/cross_model_review.py +3312 -0
  108. package/dist/templates/atw/scripts/decision_log.py +2002 -0
  109. package/dist/templates/atw/scripts/floor_guard.py +642 -0
  110. package/dist/templates/atw/scripts/task.py +25 -6
  111. package/dist/templates/atw/scripts/tickets.py +480 -10
  112. package/dist/templates/atw/workflow-general.md +6 -1
  113. package/dist/templates/atw/workflow.md +346 -26
  114. package/dist/templates/claude/settings.json +12 -0
  115. package/dist/templates/codex/hooks/session-start.py +2 -2
  116. package/dist/templates/common/bundled-skills/atw-askme/SKILL.md +1 -1
  117. package/dist/templates/common/bundled-skills/atw-askme-with-docs/SKILL.md +1 -1
  118. package/dist/templates/common/bundled-skills/atw-channel/SKILL.md +5 -2
  119. package/dist/templates/common/bundled-skills/atw-channel/references/command-reference.md +44 -5
  120. package/dist/templates/common/bundled-skills/atw-channel/references/progress-debugging.md +9 -0
  121. package/dist/templates/common/bundled-skills/atw-channel/references/workers.md +48 -4
  122. package/dist/templates/common/bundled-skills/atw-code-review/SKILL.md +78 -3
  123. package/dist/templates/common/bundled-skills/atw-codebase-design/SKILL.md +2 -2
  124. package/dist/templates/common/bundled-skills/atw-codebase-design/{DESIGN-IT-TWICE.md → design-it-twice.md} +3 -3
  125. package/dist/templates/common/bundled-skills/atw-cross-model-review/SKILL.md +361 -0
  126. package/dist/templates/common/bundled-skills/atw-cross-model-review/agents/openai.yaml +5 -0
  127. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/four-tiers.md +42 -0
  128. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/log-review-questions.md +38 -0
  129. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/questions.md +54 -0
  130. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/recheck-questions.md +42 -0
  131. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/report-format.md +22 -0
  132. package/dist/templates/common/bundled-skills/atw-diagnosing-bugs/SKILL.md +13 -2
  133. package/dist/templates/common/bundled-skills/atw-domain-modeling/SKILL.md +2 -2
  134. package/dist/templates/common/bundled-skills/atw-handoff/SKILL.md +1 -1
  135. package/dist/templates/common/bundled-skills/atw-implement/SKILL.md +37 -1
  136. package/dist/templates/common/bundled-skills/atw-implement-all/SKILL.md +219 -24
  137. package/dist/templates/common/bundled-skills/atw-implement-spec/SKILL.md +237 -18
  138. package/dist/templates/common/bundled-skills/atw-improve-codebase-architecture/SKILL.md +2 -2
  139. package/dist/templates/common/bundled-skills/atw-init-repo/SKILL.md +1 -1
  140. package/dist/templates/common/bundled-skills/atw-init-repo/references/issue-tracker-atw-local.md +7 -1
  141. package/dist/templates/common/bundled-skills/atw-init-repo/references/issue-tracker-atw.md +7 -1
  142. package/dist/templates/common/bundled-skills/atw-map/SKILL.md +1 -1
  143. package/dist/templates/common/bundled-skills/atw-meta/SKILL.md +1 -1
  144. package/dist/templates/common/bundled-skills/atw-meta/references/customize-local/add-project-local-conventions.md +4 -3
  145. package/dist/templates/common/bundled-skills/atw-meta/references/customize-local/change-skills-or-commands.md +21 -16
  146. package/dist/templates/common/bundled-skills/atw-meta/references/customize-local/change-task-lifecycle.md +1 -1
  147. package/dist/templates/common/bundled-skills/atw-meta/references/customize-local/overview.md +1 -0
  148. package/dist/templates/common/bundled-skills/atw-meta/references/local-architecture/task-system.md +2 -1
  149. package/dist/templates/common/bundled-skills/atw-meta/references/platform-files/hooks-and-settings.md +1 -0
  150. package/dist/templates/common/bundled-skills/atw-noob-mode/SKILL.md +1 -1
  151. package/dist/templates/common/bundled-skills/atw-performance/SKILL.md +134 -0
  152. package/dist/templates/common/bundled-skills/atw-performance/agents/openai.yaml +3 -0
  153. package/dist/templates/common/bundled-skills/atw-performance/references/optimization-patterns.md +250 -0
  154. package/dist/templates/common/bundled-skills/atw-performance/references/performance-checklist.md +238 -0
  155. package/dist/templates/common/bundled-skills/atw-performance/references/performance-optimization.md +264 -0
  156. package/dist/templates/common/bundled-skills/atw-prototype/SKILL.md +3 -3
  157. package/dist/templates/common/bundled-skills/atw-prototype/{LOGIC.md → logic.md} +1 -1
  158. package/dist/templates/common/bundled-skills/atw-prototype/references/design-direction.md +91 -58
  159. package/dist/templates/common/bundled-skills/atw-prototype/references/direction-persuasion.md +25 -0
  160. package/dist/templates/common/bundled-skills/atw-prototype/references/style-explorer.md +1 -1
  161. package/dist/templates/common/bundled-skills/atw-prototype/ui.md +194 -0
  162. package/dist/templates/common/bundled-skills/atw-reflect/SKILL.md +77 -0
  163. package/dist/templates/common/bundled-skills/atw-reflect/agents/openai.yaml +5 -0
  164. package/dist/templates/common/bundled-skills/atw-reflect/references/divergent-reviewer.md +38 -0
  165. package/dist/templates/common/bundled-skills/atw-reflect/references/judgment-reviewer.md +37 -0
  166. package/dist/templates/common/bundled-skills/atw-reflect/references/synthesizer.md +63 -0
  167. package/dist/templates/common/bundled-skills/atw-reflect/references/tooling-reviewer.md +48 -0
  168. package/dist/templates/common/bundled-skills/atw-security/SKILL.md +248 -0
  169. package/dist/templates/common/bundled-skills/atw-security/agents/openai.yaml +3 -0
  170. package/dist/templates/common/bundled-skills/atw-security/references/hardening-patterns.md +324 -0
  171. package/dist/templates/common/bundled-skills/atw-security/references/security-checklist.md +248 -0
  172. package/dist/templates/common/bundled-skills/atw-session-insight/SKILL.md +1 -1
  173. package/dist/templates/common/bundled-skills/atw-session-insight/references/cli-quick-reference.md +11 -2
  174. package/dist/templates/common/bundled-skills/atw-spec/SKILL.md +1 -1
  175. package/dist/templates/common/bundled-skills/atw-tdd/SKILL.md +25 -0
  176. package/dist/templates/common/bundled-skills/atw-tickets/SKILL.md +1 -1
  177. package/dist/templates/common/bundled-skills/atw-triage/SKILL.md +5 -5
  178. package/dist/templates/common/bundled-skills/atw-ui/SKILL.md +99 -31
  179. package/dist/templates/common/bundled-skills/atw-ui/references/accessibility-checklist.md +160 -0
  180. package/dist/templates/common/bundled-skills/atw-ui/references/layout-and-viewport.md +7 -5
  181. package/dist/templates/common/bundled-skills/atw-ui/references/media.md +117 -20
  182. package/dist/templates/common/bundled-skills/atw-ui/references/motion.md +47 -23
  183. package/dist/templates/common/bundled-skills/atw-ui/references/play.md +18 -0
  184. package/dist/templates/common/bundled-skills/atw-ui/references/reviewer-manual.md +73 -0
  185. package/dist/templates/common/bundled-skills/atw-ui/references/scroll-narrative.md +98 -0
  186. package/dist/templates/common/bundled-skills/atw-ui/references/tools.md +46 -0
  187. package/dist/templates/common/bundled-skills/atw-ui/references/visual-language.md +40 -24
  188. package/dist/templates/common/bundled-skills/atw-ui/references/visual-review.md +26 -82
  189. package/dist/templates/common/bundled-skills/atw-ui/scripts/shoot.mjs +1721 -0
  190. package/dist/templates/common/bundled-skills/atw-verify-skill/SKILL.md +58 -0
  191. package/dist/templates/common/bundled-skills/atw-verify-skill/agents/openai.yaml +5 -0
  192. package/dist/templates/common/bundled-skills/atw-verify-skill/references/feature-map-example/README.md +47 -0
  193. package/dist/templates/common/bundled-skills/atw-verify-skill/references/feature-map-example/create-note.md +39 -0
  194. package/dist/templates/common/bundled-skills/atw-verify-skill/references/feature-map-example/search.md +45 -0
  195. package/dist/templates/common/bundled-skills/atw-verify-skill/references/generate.md +107 -0
  196. package/dist/templates/common/bundled-skills/atw-verify-skill/references/health-check.md +100 -0
  197. package/dist/templates/common/bundled-skills/atw-verify-skill/references/verify-skill-template.md +99 -0
  198. package/dist/templates/common/bundled-skills/atw-what-happened/SKILL.md +49 -0
  199. package/dist/templates/common/bundled-skills/atw-what-happened/agents/openai.yaml +5 -0
  200. package/dist/templates/common/commands/finish-work.md +10 -1
  201. package/dist/templates/common/commands/start.md +3 -1
  202. package/dist/templates/common/skills/update-spec.md +40 -4
  203. package/dist/templates/copilot/hooks/session-start.py +2 -2
  204. package/dist/templates/dsh/DSH.md +2 -2
  205. package/dist/templates/markdown/spec/backend/quality-guidelines.md.txt +10 -0
  206. package/dist/templates/markdown/spec/frontend/quality-guidelines.md.txt +10 -0
  207. package/dist/templates/omp/config.yml +15 -0
  208. package/dist/templates/omp/extensions/atw/index.ts.txt +135 -18
  209. package/dist/templates/omp/index.d.ts +1 -0
  210. package/dist/templates/omp/index.d.ts.map +1 -1
  211. package/dist/templates/omp/index.js +3 -0
  212. package/dist/templates/omp/index.js.map +1 -1
  213. package/dist/templates/opencode/lib/session-utils.js +4 -2
  214. package/dist/templates/opencode/plugins/inject-workflow-state.js +5 -1
  215. package/dist/templates/pi/extensions/atw/index.ts.txt +80 -7
  216. package/dist/templates/shared-hooks/index.d.ts +8 -1
  217. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  218. package/dist/templates/shared-hooks/index.js +8 -0
  219. package/dist/templates/shared-hooks/index.js.map +1 -1
  220. package/dist/templates/shared-hooks/inject-workflow-state.py +131 -14
  221. package/dist/templates/shared-hooks/session-start.py +4 -2
  222. package/dist/templates/shared-hooks/show-notice.py +135 -0
  223. package/dist/templates/snow/SNOW.md +1 -1
  224. package/dist/utils/initialized.d.ts +9 -0
  225. package/dist/utils/initialized.d.ts.map +1 -0
  226. package/dist/utils/initialized.js +24 -0
  227. package/dist/utils/initialized.js.map +1 -0
  228. package/dist/utils/managed-removal.d.ts +2 -0
  229. package/dist/utils/managed-removal.d.ts.map +1 -1
  230. package/dist/utils/managed-removal.js +23 -3
  231. package/dist/utils/managed-removal.js.map +1 -1
  232. package/dist/utils/optional-skills.d.ts +2 -0
  233. package/dist/utils/optional-skills.d.ts.map +1 -1
  234. package/dist/utils/optional-skills.js +1 -1
  235. package/dist/utils/optional-skills.js.map +1 -1
  236. package/dist/utils/template-hash.d.ts.map +1 -1
  237. package/dist/utils/template-hash.js +3 -0
  238. package/dist/utils/template-hash.js.map +1 -1
  239. package/dist/utils/uninstall-scrubbers.d.ts +10 -0
  240. package/dist/utils/uninstall-scrubbers.d.ts.map +1 -1
  241. package/dist/utils/uninstall-scrubbers.js +50 -0
  242. package/dist/utils/uninstall-scrubbers.js.map +1 -1
  243. package/dist/utils/verify-entries.d.ts +51 -0
  244. package/dist/utils/verify-entries.d.ts.map +1 -0
  245. package/dist/utils/verify-entries.js +399 -0
  246. package/dist/utils/verify-entries.js.map +1 -0
  247. package/package.json +2 -2
  248. package/dist/templates/common/bundled-skills/atw-prototype/UI.md +0 -174
  249. package/dist/templates/common/bundled-skills/atw-prototype/references/visual-language.md +0 -88
  250. package/dist/templates/common/bundled-skills/atw-prototype/references/visual-review.md +0 -63
  251. package/dist/templates/common/bundled-skills/atw-ui/references/imagery.md +0 -69
  252. /package/dist/templates/common/bundled-skills/atw-codebase-design/{DEEPENING.md → deepening.md} +0 -0
  253. /package/dist/templates/common/bundled-skills/atw-domain-modeling/{ADR-FORMAT.md → adr-format.md} +0 -0
  254. /package/dist/templates/common/bundled-skills/atw-domain-modeling/{GLOSSARY-FORMAT.md → glossary-format.md} +0 -0
  255. /package/dist/templates/common/bundled-skills/atw-improve-codebase-architecture/{HTML-REPORT.md → html-report.md} +0 -0
  256. /package/dist/templates/common/bundled-skills/atw-triage/{AGENT-BRIEF.md → agent-brief.md} +0 -0
  257. /package/dist/templates/common/bundled-skills/atw-triage/{OUT-OF-SCOPE.md → out-of-scope.md} +0 -0
@@ -0,0 +1,3312 @@
1
+ #!/usr/bin/env python3
2
+ """跨模型复查脚本 —— 安装到 .atw/scripts/cross_model_review.py。
3
+
4
+ cross_model_review.py prepare --questions <题目文件>
5
+ --reviewer "<工具> model=<模型> [effort=<强度>]" [...]
6
+ [--task <任务目录>] [--intent-file <意图文件>]
7
+ [--since <起点>] [--path <文件夹或文件> ...]
8
+ [--whole | --group "<文件夹>,<文件夹>" ...]
9
+ [--expect-base <起点提交> --expect-patch <补丁哈希>]
10
+ [--recheck <快照> --picked-file <选去改的条目>]
11
+ [--pick '<这一轮 pick 输出的 JSON>'] [--lang zh|en]
12
+ cross_model_review.py prepare-log --questions <题目文件> --reviewer "<…>" [...] --task <任务目录>
13
+ [--whole | --session [对话编号]] [--pick '<pick 输出的 JSON>'] [--lang zh|en]
14
+ (日志复查的材料)
15
+ cross_model_review.py run <编号>
16
+ cross_model_review.py family | probe | pick | wizard (认家、查工具、定复查员、设置向导)
17
+ cross_model_review.py pick --settings-only [--model-line …] [--author …] (无人值守:只看设置)
18
+ cross_model_review.py afk-notice --skill <连做命令> --branch <分支> [--new-branch] [--max N] --task <任务目录>
19
+ [--model-line …] [--author …] [--lang zh|en] (无人值守的开跑提醒)
20
+ cross_model_review.py host (宿主的围栏:在不在、能否申请到围栏外)
21
+ cross_model_review.py settings | check | save (复查员设置:读、验证、验证后写入)
22
+ cross_model_review.py report --file <报告草稿> [--task <任务目录> [--log-review] | --append <已存的报告>]
23
+ (打码、存报告)
24
+
25
+ prepare:在仓库根目录冻结一份补丁、组好材料与题目,放进
26
+ `.atw/.runtime/cross-model-review/<编号>/`,标准输出给一个 JSON 对象(编号、补丁、
27
+ 起点提交、文件、总行数与各文件夹行数、跳过的像密钥文件、只记路径的文件、复查员)。
28
+ 默认范围:当前分支与主线的合并基点以来的全部改动,连同工作区,再加未跟踪(未忽略)
29
+ 的文件;排除 `.atw/tasks/`、`.atw/workspace/`;未跟踪文件里名字像密钥的不放入、只列出;
30
+ 二进制与大文件只记路径。行数是加与删合计,不含上面排除的部分。
31
+ 主线取 `--task` 的 `task.json` 里的 `base_branch`,没有任务时取仓库默认主线;就在主线上
32
+ 时只查未提交的改动;`--since` 给起点(提交、分支、标签)、`--path` 只看某些文件夹或文件。
33
+ 没有改动时给 `stop: "no-changes"`、超过 500 行时给 `stop: "over-limit"` 与按文件夹的分组
34
+ 建议,两种都不冻结;用户选了整段看加 `--whole`,选了分开看每组一个 `--group`,每组各冻结
35
+ 一份、材料相同。
36
+ 冻结先把这次看到的整个工作区状态(同样的排除规则,不收像密钥的未跟踪文件)存成一个
37
+ git 快照,补丁就从起点到这个快照算;快照挂在 `refs/atw/cross-model-review/<快照>` 下(不在
38
+ 任何分支上,不改工作区与暂存区),编号给在 `snapshot`,`--path` 的范围记在它的提交说明里;
39
+ 写材料出错退出 2 时引用与写了一半的目录一并撤掉。复核时 `--recheck <快照>` 以它为起点,
40
+ 只冻结那之后的改动(`baseFrom: "snapshot"`,不给 `--path` 时照用快照记下的范围),材料另加
41
+ `--picked-file` 给的上次选去改的条目;没有改动时给 `stop: "no-changes-since-snapshot"`,
42
+ 不冻结、不留新快照。
43
+ 冻结了的输出另带 `notice`(`zh`、`en` 各一段):开跑前那段交代,由 AI 原样贴出,只列用得上的项;
44
+ 点名的、作者是哪家、设置里谁这次不跑,取自 `--pick` 给的 pick 输出,不给时这几项不写。
45
+
46
+ run:用一条命令同时跑全部复查员——每个复查员一条 `atw channel run --json`(只读、
47
+ 干净环境、不留会话记录、标准速度档、显式模型、可选思考强度、30 分钟),材料完全相同;
48
+ 收完结果删掉补丁目录与临时频道,失败的也删。标准输出给一个 JSON 对象,每个复查员带
49
+ channel 交出的结果对象原样(`status` 分跑完、超时、出错、没起来),另加卡在哪一步
50
+ (`stage`:没开始 / 跑到一半 / 跑完但没看完)、代码是否已发出(`codeSent`)、是否已花钱
51
+ (`spent`),以及答案末尾 `## Not read` 一节认出的没看的文件与行数(`notRead`,超过 500 行
52
+ 时带按文件夹的分组建议;认不出的行进 `unmatched`,这时行数为 null)。结果里另有
53
+ 起点提交 `baseCommit` 与补丁哈希 `patchHash`:重试或换人时交给 `prepare --expect-base
54
+ --expect-patch`,它在 `sameAsBefore` 里说材料与第一次是否相同。
55
+ 时限固定 30 分钟;环境变量 `ATW_REVIEW_TIMEOUT`(`<N>s` 或 `<N>m`)只给测试缩短用,
56
+ 比 30 分钟长或写法不对时退出 2、什么都不跑。
57
+
58
+ 复查员设置整台电脑一份:`~/.atw/reviewers.yaml`(环境变量 `ATW_REVIEWERS_FILE` 可改),
59
+ 带版本号,一行一个复查员(先写工具,后接可选的 model= 与 effort=);不认识的键提醒并忽略,
60
+ 非法工具名、思考强度、缺版本号或版本更新时报错。`pick` 的优先级:当场点名 > 设置 > 自动挑;
61
+ 没点名也没设置时给 `mode: setup`(先走设置向导)。`pick --failed <工具>` 交代这次跑不起来的:
62
+ 自动挑出的给出下一个合格的(要点头),点名或设置里的不自动换人,只给出「换成自动挑出的
63
+ 某某」里的那一个。
64
+
65
+ host:这条命令是不是在宿主的围栏里跑——Codex 看 `CODEX_SANDBOX`(获批到围栏外跑的命令没有它、
66
+ 却仍带 `CODEX_SANDBOX_NETWORK_DISABLED`;只剩后者时,在 macOS 上能在 `.git` 里建文件才算出了围栏,
67
+ 网络只在 macOS 实测过,别的平台仍算在围栏里),
68
+ Claude Code 的命令围栏看 `SANDBOX_RUNTIME=1`——给 `sandboxed`、每个围栏一条 `sandboxes`(`by`、
69
+ `networkDisabled`、`canRequest`、`requestWith`、`source`)与合起来的 `canRequest`(有一个 `no` 就是
70
+ `no`,否则有 `unknown` 就是 `unknown`,否则 `yes`;没有围栏时 null)。能否申请按实际生效的设置:
71
+ Codex 读本次会话记录最后一轮的 `approval_policy`(含启动时的开关与 profile),`never` 为 `no`、其余为
72
+ `yes`;读不到记录而配置(顶层或当前 profile)写 `never` 时为 `unknown`(可能已被启动开关盖过,先申请
73
+ 一次)。Claude Code 设置(托管 > 本地 > 项目 > 用户,托管或用户的 false 总作数)里
74
+ `sandbox.allowUnsandboxedCommands` 定为 false 时为 `no`。`source` 是定下取值的记录或文件。
75
+ 申请获批后在围栏外再跑一次 `host`,`sandboxed` 为 false 才算真出来了。
76
+
77
+ report:读主 AI 写好的复查报告草稿(存好后删掉;出错退出 2 时留着并在报错里写明),把像密钥的内容整段换成标明类型的占位
78
+ (「[已打码:私钥]」之类,规则见 `SECRET_PATTERNS` 与 `_NAMED_SECRET`),标准输出给打码后的
79
+ `text`、打了几处 `redactedCount`、每处的类型与行号和所在小节 `redactions`;给了 `--task` 时把
80
+ 同一份打码后的文字存成任务目录下 `cross-model-review/<日期>-<时分秒>.md`(`saved`),没给时
81
+ 不写文件;`--append <已存的报告>` 把草稿(用户选去改的条目)打码后追加到那份报告末尾,行号接着它算。
82
+ 聊天里显示与存档都用这份 `text`。冻结补丁排除 `.atw/tasks/`,存下的报告不会被
83
+ 下次复查当成改动。
84
+
85
+ prepare-log(日志复查,prd K 节):不冻结补丁、没有 500 行停顿。材料是任务决定日志里最近一次运行
86
+ 那一段(最后一条 start 行那个对话写的行;`--whole` 时整份;`--session <编号>` 时只取那个对话写的行,不带值时是这次
87
+ 对话——`resolve_context_key`,与 decision_log 自查的 `run` 同一个标识,无人值守收尾用它),加那次运行的聊天记录——按 start 行证据格
88
+ 的对话编号跑一次 `atw mem extract --context <编号> --tools --after <start 行时间> --uncut --json`,取不截短的完整
89
+ 条目,每条先用 report 的规则整段打码,再照 atw mem 的规则截短(参数值、换路引的话、首尾行)、渲染成它 `--tools`
90
+ 文字输出的样子:命令与结果留开头
91
+ 结尾各 20 行,另一份各 5 行的供「材料太长」重试,两份出自同一次读取;日志也打码,一起写进
92
+ `.atw/.runtime/cross-model-review/<编号>/`,打码处数与位置按完整版算。输出 `kind: "log-review"`、`stop`、`scope`(`last-run`、`session`、`whole`)、`session`(只在给了 `--session` 时有)、`runs`、`rows`、
93
+ `id`、`prompt`、`files`、`redactedCount`、`redactions`、`reviewers`、`chatProblems`、`notice`。没有日志给
94
+ `stop: "no-log"`;`--session` 指定的对话在日志里没有 start 行(或这次对话的编号查不到,`session` 为 null)给
95
+ `stop: "no-run"`,什么都不读、不写,不改看别的对话;有一次运行的聊天记录找不到给 `stop: "chat-not-found"`,`chatProblems` 每条写原因
96
+ (`no-session-id`、`manual-override`——编号不带 ATW 认得的平台前缀,多半来自 `ATW_CONTEXT_ID` 手动覆盖、
97
+ `unreadable-id`——ATW 认得的平台但 atw mem 读不了、
98
+ `not-found`、`ambiguous`、`mem-failed`),什么都不写、不拿别的对话充数。
99
+ 日志复查的 `run` 按失败原因自动重试(先按工具报的出错种类 `errorKind` 归类,见 `ERROR_KIND_REASONS`,
100
+ Claude `rate_limit` 带 `QUOTA_SUBKINDS` 里的 `errorSubkind` 算额度用完;
101
+ 没有种类或种类不在表里时按报错文字,见 `FAILURE_PATTERNS`):`transient` 原样重试、
102
+ `too-long` 改用各 5 行的材料、`unknown` 重试一次,每个复查员最多两次;`tool-error`、`not-installed`、
103
+ `not-logged-in`、`quota-exhausted`、`model-unavailable`、`timeout`(半截照用)不重试。一个复查员的全部尝试
104
+ 合计不超过 30 分钟:重试只用剩下的时间,剩下不到十分之一就不再试(`retryStoppedBy: "time-limit"`)。每个
105
+ 复查员另带 `failureReason`、`retries`、每次尝试的 `attempts` 与各次合计的 `total`(`durationMs`、`costUsd`、
106
+ `costUnreported`——有一次没报花费即为真),`codeSent`、`spent` 合计各次。
107
+ 日志复查的 `notRead` 不拿补丁文件去对:只有 `text` 与 `parts`(`## Not read` 各条原样,写 `None`/「无」时为空),
108
+ 没写这一节或标题下面空着为 null。
109
+ `pick --settings-only`:无人值守时只用设置里的复查员,不自动挑、不换人、不弹向导;用不成时退出 0、
110
+ `unavailable` 给 `{reason, detail}`(`no-settings`、`settings-broken`、`current-ai-unknown`、
111
+ `sandbox-needs-approval`——照 `host` 判断命令在宿主围栏里、出去要用户点头、`none-qualified`);
112
+ `logReviewCommand` 给当前宿主里日志复查的输入 `{zh, en}`(与 `afk-notice` 同一套写法),认不出宿主时为 null。
113
+ `report --log-review --task <任务目录>`:日志复查的报告存进任务目录的 `log-review/`,不进 `cross-model-review/`。
114
+ `afk-notice`(prd F 节):两个连做命令 `--afk` 开跑时用。照 `pick --settings-only` 挑人、原样给出它的输出,另带
115
+ `notice`(`zh`、`en` 各一段):分支(新开还是原有)、收尾请谁做日志复查(家、按名字认出、认不出照跑、设置里谁不跑)
116
+ 或为什么跳过与现在怎么补、事后怎么补、会发出去什么、宿主会不会逐条要授权(Claude Code 设置的 `permissions.defaultMode`、
117
+ Codex 会话记录或配置的 `approval_policy`,查不到就说查不到)、五条红线、「等你处理」清单位置、随时可打断;
118
+ 命令只给当前宿主的写法,认不出宿主时三种都给。AI 原样贴出,不改写、不压缩。
119
+
120
+ 交给钩子显示(工单 26):`afk-notice`、`prepare`、`prepare-log` 解析参数之前先删 `.atw/.runtime/hook-notice.json`
121
+ (`.atw/` 所在目录按 `common.paths.get_repo_root` 找,不是 git 顶层),输出带 `notice` 时把 `--lang` 那一种语言
122
+ (不给时中、英两段,中文在前、空一行)连同命令名与这次对话的 ATW 会话编号(`resolve_context_key`,拿不到为 null)
123
+ 写进去;参数不对、停下、出错退出 2 都不留。Claude Code 的 PostToolUse 钩子 `show-notice.py` 在同名命令跑完后、
124
+ 同一个对话里(任一边没有编号时不比)把它作为 `systemMessage` 原样显示给用户(不经模型)、删掉文件,并用
125
+ `additionalContext` 告诉 AI 已显示过;超过 8000 字(Claude Code 上限 10000)不显示,改让 AI 自己原样贴出。
126
+ 三个命令名另写在钩子的 `COMMAND_RE` 里,改一处要改另一处。别的宿主没有这个钩子,照旧由 AI 原样贴出。
127
+
128
+ 退出码:0 成功(复查员各自成败看 JSON);2 没跑起来(参数不对、起点认不出、
129
+ 编号不存在、找不到 atw 命令),错误写到标准错误。
130
+ """
131
+ from __future__ import annotations
132
+
133
+ import argparse
134
+ import contextlib
135
+ import hashlib
136
+ import io
137
+ import json
138
+ import os
139
+ import posixpath
140
+ import re
141
+ import secrets
142
+ import shutil
143
+ import subprocess
144
+ import sys
145
+ import tempfile
146
+ import threading
147
+ import time
148
+ from collections.abc import Callable
149
+ from pathlib import Path
150
+
151
+ from common import configure_encoding
152
+ from common.active_task import KNOWN_PLATFORMS, resolve_context_key
153
+ from common.atw_config import parse_simple_yaml
154
+ from common.paths import get_repo_root
155
+ from decision_log import LANGS as LOG_LANGS
156
+ from decision_log import START_PHASE, LogError, Row, format_row, owners, read_log
157
+
158
+ EXIT_OK = 0
159
+ EXIT_ERROR = 2
160
+
161
+ TOOLS = ("claude", "codex", "pi", "omp")
162
+ REVIEW_TIMEOUT_S = 30 * 60
163
+ RUNTIME_SUBDIR = Path(".atw/.runtime/cross-model-review")
164
+ EXCLUDED_PREFIXES = (".atw/tasks/", ".atw/workspace/", ".atw/.runtime/")
165
+ # 开跑前那段交代交给 Claude Code 的 PostToolUse 钩子 show-notice.py 原样显示(工单 26)
166
+ HOOK_NOTICE = Path(".atw/.runtime/hook-notice.json")
167
+ # 与 show-notice.py 的 COMMAND_RE 是同一组命令名:改一处要改另一处
168
+ NOTICE_COMMANDS = ("afk-notice", "prepare", "prepare-log")
169
+ # 单个文件的改动超过这么多字节就只记路径,免得一个产物文件塞满复查员的上下文
170
+ LARGE_FILE_BYTES = 256 * 1024
171
+ SECRET_SUFFIXES = (".pem", ".key", ".p12", ".pfx")
172
+ SECRET_PREFIXES = (".env", "id_rsa", "id_ed25519")
173
+ # 补丁超过这么多行(加与删合计)先停下,由用户选整段看还是分开看
174
+ LINE_LIMIT = 500
175
+
176
+
177
+ class ReviewError(Exception):
178
+ """没跑起来:参数、仓库状态或环境不对。"""
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # git
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ def _git(
187
+ root: Path,
188
+ args: list[str],
189
+ ok_codes: tuple[int, ...] = (0,),
190
+ env: dict[str, str] | None = None,
191
+ stdin: str | None = None,
192
+ ) -> str:
193
+ proc = subprocess.run(
194
+ ["git", "-c", "core.quotepath=off", *args],
195
+ cwd=root,
196
+ capture_output=True,
197
+ text=True,
198
+ encoding="utf-8",
199
+ errors="replace",
200
+ env=env,
201
+ input=stdin,
202
+ )
203
+ if proc.returncode not in ok_codes:
204
+ raise ReviewError(f"git {' '.join(args)} 失败:{proc.stderr.strip()}")
205
+ return proc.stdout
206
+
207
+
208
+ DIFF_FLAGS = ["--no-color", "--no-ext-diff", "--no-renames", "--src-prefix=a/", "--dst-prefix=b/"]
209
+
210
+
211
+ def repo_root() -> Path:
212
+ out = _git(Path.cwd(), ["rev-parse", "--show-toplevel"])
213
+ return Path(out.strip())
214
+
215
+
216
+ def _excluded(path: str) -> bool:
217
+ return any(path.startswith(p) for p in EXCLUDED_PREFIXES)
218
+
219
+
220
+ def is_secret_like(path: str) -> bool:
221
+ name = posixpath.basename(path).lower()
222
+ return (
223
+ name.startswith(SECRET_PREFIXES)
224
+ or name.endswith(SECRET_SUFFIXES)
225
+ or "credentials" in name
226
+ )
227
+
228
+
229
+ def _current_branch(root: Path) -> str | None:
230
+ return _git(root, ["branch", "--show-current"]).strip() or None
231
+
232
+
233
+ def _task_base_branch(task: Path | None) -> str | None:
234
+ if task is None or not (task / "task.json").is_file():
235
+ return None
236
+ try:
237
+ data = json.loads((task / "task.json").read_text(encoding="utf-8"))
238
+ except ValueError as e:
239
+ raise ReviewError(f"读不了 {task / 'task.json'}:{e}") from e
240
+ base = data.get("base_branch") if isinstance(data, dict) else None
241
+ return base if isinstance(base, str) and base.strip() else None
242
+
243
+
244
+ def _default_branch(root: Path) -> str:
245
+ """仓库默认主线:本地记下的 origin/HEAD 指向的;没有时取 init.defaultBranch、main、master 里先有的。
246
+
247
+ 只看本地引用,不连远端(`git remote show origin` 会走网络)。
248
+ """
249
+ head = _git(root, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], ok_codes=(0, 1)).strip()
250
+ name = head.split("/", 1)[1] if head.startswith("origin/") else None
251
+ if name is None:
252
+ configured = _git(root, ["config", "--get", "init.defaultBranch"], ok_codes=(0, 1)).strip()
253
+ name = next((n for n in (configured, "main", "master") if n and _ref_exists(root, n)), None)
254
+ if name is None:
255
+ raise ReviewError("认不出仓库的默认主线,用 --since 指定起点")
256
+ return name
257
+
258
+
259
+ def resolve_start(root: Path, since: str | None, task: Path | None) -> dict:
260
+ """起点:用户给的,否则任务的 base_branch,否则仓库默认主线;就在主线上时从 HEAD 起。"""
261
+ if since is not None:
262
+ if not _ref_exists(root, since):
263
+ raise ReviewError(f"认不出起点 {since}(不是提交、分支或标签)")
264
+ commit = _git(root, ["merge-base", since, "HEAD"]).strip()
265
+ return {"base": since, "baseFrom": "since", "onBase": False, "baseCommit": commit}
266
+ name = _task_base_branch(task)
267
+ base_from = "task"
268
+ if name is None:
269
+ name, base_from = _default_branch(root), "default"
270
+ ref = next((r for r in (name, f"origin/{name}") if _ref_exists(root, r)), None)
271
+ if ref is None:
272
+ raise ReviewError(f"认不出主线 {name}(本地与 origin 上都没有这个分支)")
273
+ # `origin/main` 这类远端跟踪名,与本地同名分支算同一条主线
274
+ local = name.split("/", 1)[1] if "/" in name and _ref_exists(root, f"refs/remotes/{name}") else name
275
+ on_base = _current_branch(root) in (name, local)
276
+ commit = _git(root, ["rev-parse", "HEAD"] if on_base else ["merge-base", ref, "HEAD"]).strip()
277
+ return {"base": name, "baseFrom": base_from, "onBase": on_base, "baseCommit": commit}
278
+
279
+
280
+ def _in_member(path: str, member: str) -> int | None:
281
+ """path 落在分组成员(文件夹或文件)里时返回匹配长度;`.` 只管顶层的文件。"""
282
+ m = member.strip().strip("/")
283
+ if m in ("", "."):
284
+ return 0 if "/" not in path else None
285
+ if path == m or path.startswith(m + "/"):
286
+ return len(m)
287
+ return None
288
+
289
+
290
+ def assign_groups(paths: list[str], groups: list[list[str]]) -> list[int | None]:
291
+ """每个文件归到成员最长匹配的那组;哪组都不在时为 None。"""
292
+ out: list[int | None] = []
293
+ for path in paths:
294
+ best, best_len = None, -1
295
+ for i, members in enumerate(groups):
296
+ for m in members:
297
+ n = _in_member(path, m)
298
+ if n is not None and n > best_len:
299
+ best, best_len = i, n
300
+ out.append(best)
301
+ return out
302
+
303
+
304
+ def suggest_groups(lines_by_folder: dict[str, int], path_only: list[dict]) -> list[list[str]]:
305
+ """按文件夹顺序装组,每组不超过 LINE_LIMIT 行;一个文件夹自己就超过的单独成组。"""
306
+ folders = set(lines_by_folder) | {posixpath.dirname(x["path"]) or "." for x in path_only}
307
+ groups: list[list[str]] = []
308
+ current: list[str] = []
309
+ n = 0
310
+ for folder in sorted(folders):
311
+ c = lines_by_folder.get(folder, 0)
312
+ if current and n + c > LINE_LIMIT:
313
+ groups.append(current)
314
+ current, n = [], 0
315
+ current.append(folder)
316
+ n += c
317
+ if current:
318
+ groups.append(current)
319
+ return groups
320
+
321
+
322
+ def summarize(pieces: dict[str, str], lines: dict[str, int], path_only: list[dict]) -> dict:
323
+ files = sorted(pieces)
324
+ by_folder: dict[str, int] = {}
325
+ for path in files:
326
+ folder = posixpath.dirname(path) or "."
327
+ by_folder[folder] = by_folder.get(folder, 0) + lines[path]
328
+ patch_text = "".join(pieces[p] for p in files)
329
+ return {
330
+ "patchText": patch_text,
331
+ "patchHash": hashlib.sha256(patch_text.encode("utf-8")).hexdigest(),
332
+ "files": files,
333
+ "totalLines": sum(lines[p] for p in files),
334
+ "linesByFolder": by_folder,
335
+ "linesByFile": {p: lines[p] for p in files},
336
+ "pathOnly": sorted(path_only, key=lambda x: x["path"]),
337
+ }
338
+
339
+
340
+ def _untracked(root: Path, pathspec: list[str], env: dict[str, str] | None = None) -> tuple[list[str], list[str]]:
341
+ """未跟踪(未忽略)的文件,去掉排除的部分,分成(收的,名字像密钥、只列出的)。快照与冻结共用。"""
342
+ out = _git(root, ["ls-files", "--others", "--exclude-standard", "-z", "--", *pathspec], env=env)
343
+ kept: list[str] = []
344
+ secret_like: list[str] = []
345
+ for path in out.split("\0"):
346
+ if path and not _excluded(path):
347
+ (secret_like if is_secret_like(path) else kept).append(path)
348
+ return kept, secret_like
349
+
350
+
351
+ def freeze(root: Path, base_commit: str, snapshot: str, paths: list[str]) -> dict:
352
+ """冻结起点到这次快照之间(限于 paths)的补丁;返回各文件的补丁与统计,不写任何文件。
353
+
354
+ 未跟踪的文件已在快照里;名字像密钥的没进快照,这里只列出。
355
+ """
356
+ pathspec = (paths or ["."]) + [f":(exclude){p}" for p in EXCLUDED_PREFIXES]
357
+ pieces: dict[str, str] = {}
358
+ lines: dict[str, int] = {}
359
+ path_only: list[dict] = []
360
+
361
+ numstat = _git(
362
+ root,
363
+ ["diff", "--no-renames", "-z", "--numstat", base_commit, snapshot, "--", *pathspec],
364
+ )
365
+ for record in numstat.split("\0"):
366
+ if not record:
367
+ continue
368
+ added, deleted, path = record.split("\t", 2)
369
+ if added == "-" and deleted == "-":
370
+ path_only.append({"path": path, "reason": "binary"})
371
+ continue
372
+ text = _git(root, ["diff", *DIFF_FLAGS, base_commit, snapshot, "--", path])
373
+ if len(text.encode("utf-8")) > LARGE_FILE_BYTES:
374
+ path_only.append({"path": path, "reason": "large"})
375
+ continue
376
+ pieces[path] = text
377
+ lines[path] = int(added) + int(deleted)
378
+
379
+ skipped = _untracked(root, pathspec)[1]
380
+ return {"pieces": pieces, "lines": lines, "pathOnly": path_only, "skippedSecretLike": sorted(skipped)}
381
+
382
+
383
+ SNAPSHOT_REF_PREFIX = "refs/atw/cross-model-review/"
384
+ # 快照提交的作者固定写 ATW,不依赖用户有没有配 user.name
385
+ _SNAPSHOT_IDENT = {
386
+ "GIT_AUTHOR_NAME": "ATW cross-model review",
387
+ "GIT_AUTHOR_EMAIL": "atw@localhost",
388
+ "GIT_COMMITTER_NAME": "ATW cross-model review",
389
+ "GIT_COMMITTER_EMAIL": "atw@localhost",
390
+ }
391
+
392
+
393
+ SNAPSHOT_PATHS_TRAILER = "ATW-Paths: "
394
+
395
+
396
+ def snapshot_commit(root: Path, paths: list[str]) -> str:
397
+ """把工作区此刻的状态(与冻结同样的排除规则)写成一个提交,不挂引用。
398
+
399
+ 在暂存区的副本上做,用户的暂存区、工作区与分支都不动;没跟踪的里名字像密钥的不收。
400
+ """
401
+ real_index = Path(_git(root, ["rev-parse", "--path-format=absolute", "--git-path", "index"]).strip())
402
+ fd, tmp = tempfile.mkstemp(prefix="atw-xmr-index-")
403
+ os.close(fd)
404
+ try:
405
+ if real_index.is_file():
406
+ # 连修改时间一起复制:git 靠暂存区文件的时间认出同一秒里改过、大小没变的文件
407
+ shutil.copy2(real_index, tmp)
408
+ else:
409
+ os.unlink(tmp)
410
+ env = {**os.environ, "GIT_INDEX_FILE": tmp, **_SNAPSHOT_IDENT}
411
+ excludes = [f":(exclude){p}" for p in EXCLUDED_PREFIXES]
412
+ wanted = _untracked(root, ["."], env=env)[0]
413
+ _git(root, ["add", "--update", "--", ".", *excludes], env=env)
414
+ if wanted:
415
+ _git(
416
+ root,
417
+ ["add", "--pathspec-from-file=-", "--pathspec-file-nul", "--"],
418
+ env=env,
419
+ stdin="\0".join(wanted),
420
+ )
421
+ tree = _git(root, ["write-tree"], env=env).strip()
422
+ parent = ["-p", "HEAD"] if _ref_exists(root, "HEAD") else []
423
+ # 这次只看了哪些文件夹或文件,记在提交说明里,复核照用
424
+ message = f"ATW cross-model review snapshot\n\n{SNAPSHOT_PATHS_TRAILER}{json.dumps(paths, ensure_ascii=False)}\n"
425
+ return _git(root, ["commit-tree", tree, *parent, "-m", message], env=env).strip()
426
+ finally:
427
+ with contextlib.suppress(FileNotFoundError):
428
+ os.unlink(tmp)
429
+
430
+
431
+ def snapshot_paths(root: Path, commit: str) -> list[str]:
432
+ """快照记下的范围(`--path`);没记的(不是本脚本存的提交)当作整个仓库。"""
433
+ for line in _git(root, ["log", "-1", "--format=%B", commit]).splitlines():
434
+ if line.startswith(SNAPSHOT_PATHS_TRAILER):
435
+ with contextlib.suppress(ValueError):
436
+ recorded = json.loads(line[len(SNAPSHOT_PATHS_TRAILER):])
437
+ if isinstance(recorded, list) and all(isinstance(x, str) for x in recorded):
438
+ return recorded
439
+ return []
440
+
441
+
442
+ def keep_snapshot(root: Path, commit: str) -> None:
443
+ """挂到 ATW 内部引用名下,免得被 git 当垃圾清掉;不在 refs/heads 下,不算分支。"""
444
+ _git(root, ["update-ref", SNAPSHOT_REF_PREFIX + commit, commit])
445
+
446
+
447
+ def _ref_exists(root: Path, ref: str) -> bool:
448
+ proc = subprocess.run(
449
+ ["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
450
+ cwd=root,
451
+ capture_output=True,
452
+ text=True,
453
+ )
454
+ return proc.returncode == 0
455
+
456
+
457
+ # ---------------------------------------------------------------------------
458
+ # 复查员
459
+ # ---------------------------------------------------------------------------
460
+
461
+
462
+ def parse_reviewer(spec: str) -> dict:
463
+ """`<工具> model=<模型> [effort=<强度>]` → {tool, model, effort}。"""
464
+ parts = spec.split()
465
+ if not parts or parts[0] not in TOOLS:
466
+ raise ReviewError(f"复查员「{spec}」要以工具名开头:{' / '.join(TOOLS)}")
467
+ fields: dict[str, str] = {}
468
+ for part in parts[1:]:
469
+ key, sep, value = part.partition("=")
470
+ if not sep or not value or key not in ("model", "effort"):
471
+ raise ReviewError(f"复查员「{spec}」里的「{part}」不认识,只认 model= 与 effort=")
472
+ fields[key] = value
473
+ if "model" not in fields:
474
+ raise ReviewError(f"复查员「{spec}」没写 model=:模型总是显式交给工具")
475
+ return {"tool": parts[0], "model": fields["model"], "effort": fields.get("effort")}
476
+
477
+
478
+ # ---------------------------------------------------------------------------
479
+ # 复查员设置(prd F;决策记录 0001:整台电脑一份)
480
+ # ---------------------------------------------------------------------------
481
+
482
+ SETTINGS_VERSION = 1
483
+ # 各工具自己的思考强度档,与 atw channel 各对接的校验一致
484
+ EFFORT_LEVELS: dict[str, tuple[str, ...]] = {
485
+ "claude": ("low", "medium", "high", "xhigh", "max", "ultracode"),
486
+ "codex": ("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "persistent"),
487
+ "pi": ("off", "minimal", "low", "medium", "high", "xhigh", "max"),
488
+ "omp": ("off", "minimal", "low", "medium", "high", "xhigh", "max"),
489
+ }
490
+
491
+
492
+ def settings_path() -> Path:
493
+ env = os.environ.get("ATW_REVIEWERS_FILE")
494
+ return Path(env) if env else Path.home() / ".atw" / "reviewers.yaml"
495
+
496
+
497
+ def _check_effort(spec: str, tool: str, effort: str | None) -> None:
498
+ if effort is not None and effort not in EFFORT_LEVELS[tool]:
499
+ raise ReviewError(
500
+ f"复查员「{spec}」的思考强度 {effort} {tool} 没有,只认:{' / '.join(EFFORT_LEVELS[tool])}"
501
+ )
502
+
503
+
504
+ def parse_settings_line(spec: str, warnings: list[str]) -> dict:
505
+ """设置里的一行:先写工具,后接可选的 model= 与 effort=;不认识的写法提醒并忽略(向后兼容)。"""
506
+ parts = spec.split()
507
+ if not parts or parts[0] not in TOOLS:
508
+ raise ReviewError(f"复查员「{spec}」要以工具名开头:{' / '.join(TOOLS)}")
509
+ fields: dict[str, str] = {}
510
+ for part in parts[1:]:
511
+ key, sep, value = part.partition("=")
512
+ if sep and value and key in ("model", "effort"):
513
+ fields[key] = value
514
+ else:
515
+ warnings.append(f"复查员「{spec}」里的「{part}」不认识,已忽略")
516
+ _check_effort(spec, parts[0], fields.get("effort"))
517
+ return {"tool": parts[0], "model": fields.get("model"), "effort": fields.get("effort")}
518
+
519
+
520
+ def _parse_settings_yaml(text: str, path: Path, warnings: list[str]) -> dict:
521
+ """简易 YAML 解析器把读不了的写法印到标准错误;收进 warnings,好让用户听到。"""
522
+ captured = io.StringIO()
523
+ with contextlib.redirect_stderr(captured):
524
+ data = parse_simple_yaml(text, source=str(path))
525
+ warnings.extend(line for line in captured.getvalue().splitlines() if line.strip())
526
+ return data
527
+
528
+
529
+ def _check_version(version: object) -> str:
530
+ if not isinstance(version, str) or not version.isdigit():
531
+ raise ReviewError("没写版本号(第一行应是 version: 1)")
532
+ if int(version) > SETTINGS_VERSION:
533
+ raise ReviewError(f"版本 {version} 比这个 ATW 认得的 {SETTINGS_VERSION} 新:请升级 ATW")
534
+ return version
535
+
536
+
537
+ def read_settings() -> dict:
538
+ """读设置文件;没有文件时 exists 为 False、reviewers 为空。写法不对时报错并写明文件。"""
539
+ path = settings_path()
540
+ out: dict = {"file": str(path), "exists": False, "version": None, "reviewers": [], "warnings": []}
541
+ try:
542
+ text = path.read_text(encoding="utf-8")
543
+ except FileNotFoundError:
544
+ return out
545
+ except OSError as e:
546
+ raise ReviewError(f"读不了复查员设置 {path}:{e}") from e
547
+ out["exists"] = True
548
+ data = _parse_settings_yaml(text, path, out["warnings"])
549
+ try:
550
+ out["version"] = _check_version(data.get("version"))
551
+ for key in data:
552
+ if key not in ("version", "reviewers"):
553
+ out["warnings"].append(f"不认识的键「{key}」,已忽略")
554
+ lines = data.get("reviewers", [])
555
+ if lines == {}:
556
+ lines = [] # `reviewers:` 下面一条都没写
557
+ if not isinstance(lines, list):
558
+ raise ReviewError("reviewers 要写成一行一个复查员的列表")
559
+ out["reviewers"] = [parse_settings_line(line, out["warnings"]) for line in lines]
560
+ except ReviewError as e:
561
+ raise ReviewError(f"复查员设置 {path} 不对:{e}") from e
562
+ return out
563
+
564
+
565
+ # ---------------------------------------------------------------------------
566
+ # 认家(prd G)
567
+ # ---------------------------------------------------------------------------
568
+
569
+ # 家 → (名字任何位置的整词, 只认整名或开头的短词)
570
+ FAMILY_WORDS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
571
+ "Anthropic": (("claude", "anthropic"), ()),
572
+ "OpenAI": (("gpt", "chatgpt", "codex", "openai"), ("o1", "o3", "o4")),
573
+ "Google": (("gemini", "gemma"), ()),
574
+ "xAI": (("grok",), ()),
575
+ "Meta": (("llama",), ()),
576
+ "Mistral": (
577
+ ("mistral", "mixtral", "codestral", "devstral", "magistral", "ministral", "pixtral"),
578
+ (),
579
+ ),
580
+ "DeepSeek": (("deepseek",), ()),
581
+ "Alibaba": (("qwen", "qwq"), ()),
582
+ "Moonshot": (("kimi", "moonshot"), ()),
583
+ "Zhipu": (("glm", "chatglm"), ()),
584
+ "MiniMax": (("minimax",), ()),
585
+ "Cohere": (("cohere", "aya"), ("command",)),
586
+ "Microsoft": ((), ("phi",)),
587
+ "ByteDance": (("doubao",), ()),
588
+ "Tencent": (("hunyuan",), ()),
589
+ "Baidu": (("ernie",), ()),
590
+ "Cursor": ((), ("composer",)),
591
+ }
592
+ # Claude Code 的模型简称:只在工具是 Claude Code 时算 Anthropic,只认整名或开头
593
+ CLAUDE_CODE_ALIASES = ("opus", "sonnet", "haiku", "opusplan", "default", "best", "fable")
594
+
595
+
596
+ def _starts_with_word(name: str, word: str) -> bool:
597
+ if not name.startswith(word):
598
+ return False
599
+ rest = name[len(word):]
600
+ if not rest:
601
+ return True
602
+ nxt = rest[0]
603
+ # 词以字母结尾时数字也算分隔(phi4);以数字结尾时只认非字母数字(o3-mini,不认 o35)
604
+ return not nxt.isalpha() and not (word[-1].isdigit() and nxt.isdigit())
605
+
606
+
607
+ def families_of(model: str, tool: str | None = None) -> list[str]:
608
+ """按模型自己的名字认家;转发服务名(最后一个 `/` 之前)不算;认不出给空列表。"""
609
+ name = model.strip().lower().rsplit("/", 1)[-1]
610
+ # 整词:两侧是名字开头结尾或非字母字符,数字也算分隔
611
+ words = set(re.findall(r"[a-z]+", name))
612
+ found: list[str] = []
613
+ for family, (anywhere, leading) in FAMILY_WORDS.items():
614
+ if words.intersection(anywhere) or any(_starts_with_word(name, w) for w in leading):
615
+ found.append(family)
616
+ if tool == "claude" and "Anthropic" not in found:
617
+ if any(_starts_with_word(name, w) for w in CLAUDE_CODE_ALIASES):
618
+ found.append("Anthropic")
619
+ return found
620
+
621
+
622
+ # ---------------------------------------------------------------------------
623
+ # 查工具(prd F;只用调研 02 里不调模型、不吐密钥的办法)
624
+ # ---------------------------------------------------------------------------
625
+
626
+ AUTO_PICK_ORDER = ("codex", "claude", "pi", "omp")
627
+ # 每条查询命令最多等这么久;环境变量可改(测试与很慢的机器用)
628
+ PROBE_TIMEOUT_S = float(os.environ.get("ATW_REVIEW_PROBE_TIMEOUT_S") or 60)
629
+ # omp 配置里 `<模型>:<思考强度>` 的后缀;auto 会另调评判模型,channel 不收,按不写处理
630
+ OMP_THINKING_SUFFIXES = ("off", "minimal", "low", "medium", "high", "xhigh", "max", "auto")
631
+
632
+
633
+ class ProbeFailed(Exception):
634
+ """查询命令本身出了问题(起不来、超时、输出不是约定的样子):不等于没登录。"""
635
+
636
+ def __init__(self, reason: str) -> None:
637
+ super().__init__(reason)
638
+ self.reason = reason
639
+
640
+
641
+ def _probe_cmd(args: list[str]) -> tuple[int, str]:
642
+ """跑一条查询命令,返回退出码与标准输出;标准错误一律丢掉(Codex 会在那里印半截密钥)。"""
643
+ try:
644
+ proc = subprocess.run(
645
+ args,
646
+ stdin=subprocess.DEVNULL,
647
+ stdout=subprocess.PIPE,
648
+ stderr=subprocess.DEVNULL,
649
+ text=True,
650
+ encoding="utf-8",
651
+ errors="replace",
652
+ timeout=PROBE_TIMEOUT_S,
653
+ )
654
+ except subprocess.TimeoutExpired as e:
655
+ raise ProbeFailed("probe-timed-out") from e
656
+ except OSError as e:
657
+ raise ProbeFailed("probe-failed") from e
658
+ return proc.returncode, proc.stdout
659
+
660
+
661
+ def _probe_json(args: list[str], ok_codes: tuple[int, ...] = (0,)) -> tuple[int, dict]:
662
+ code, out = _probe_cmd(args)
663
+ try:
664
+ parsed = json.loads(out)
665
+ except ValueError as e:
666
+ raise ProbeFailed("probe-failed") from e
667
+ if code not in ok_codes or not isinstance(parsed, dict):
668
+ raise ProbeFailed("probe-failed")
669
+ return code, parsed
670
+
671
+
672
+ def _logged_in_by_exit(code: int) -> bool:
673
+ """codex login status 与 pi auth check:0 已登录,1 没登录,其余是命令出错。"""
674
+ if code not in (0, 1):
675
+ raise ProbeFailed("probe-failed")
676
+ return code == 0
677
+
678
+
679
+ def _read_json(path: Path) -> object:
680
+ try:
681
+ return json.loads(path.read_text(encoding="utf-8"))
682
+ except (OSError, ValueError):
683
+ return None
684
+
685
+
686
+ def _settings_model(path: Path) -> str | None:
687
+ settings = _read_json(path)
688
+ model = settings.get("model") if isinstance(settings, dict) else None
689
+ return model if isinstance(model, str) and model else None
690
+
691
+
692
+ def _claude_default_model() -> str:
693
+ """调研 02 §2.1 的优先级:ANTHROPIC_MODEL > 设置文件(本地 > 项目 > 用户)> ANTHROPIC_DEFAULT_MODEL。"""
694
+ env = os.environ.get("ANTHROPIC_MODEL")
695
+ if env:
696
+ return env
697
+ user_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
698
+ # 复查从仓库根目录跑,项目设置就在当前目录
699
+ for f in (Path.cwd() / ".claude/settings.local.json", Path.cwd() / ".claude/settings.json", user_dir / "settings.json"):
700
+ model = _settings_model(f)
701
+ if model:
702
+ return model
703
+ env = os.environ.get("ANTHROPIC_DEFAULT_MODEL")
704
+ if env:
705
+ return env
706
+ # 都没设:以 Claude Code 的 default 简称为准,具体型号跑完再记(prd F 例外)
707
+ return "default"
708
+
709
+
710
+ def _codex_home() -> Path:
711
+ return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
712
+
713
+
714
+ def _codex_default_model() -> str | None:
715
+ try:
716
+ text = (_codex_home() / "config.toml").read_text(encoding="utf-8")
717
+ except OSError:
718
+ return None
719
+ for line in text.splitlines():
720
+ if line.lstrip().startswith("["):
721
+ break # 只看顶层键,profile 里的 model 不是默认
722
+ m = re.match(r"""\s*model\s*=\s*["']([^"']+)["']""", line)
723
+ if m:
724
+ return m.group(1)
725
+ return None
726
+
727
+
728
+ def _claude_logged_in() -> bool:
729
+ _, status = _probe_json(["claude", "auth", "status", "--json"], ok_codes=(0, 1))
730
+ # 只读 loggedIn;JSON 里的邮箱与组织名不读、不输出
731
+ if not isinstance(status.get("loggedIn"), bool):
732
+ raise ProbeFailed("probe-failed")
733
+ return status["loggedIn"]
734
+
735
+
736
+ def _probe_claude(r: dict) -> None:
737
+ r["defaultModel"] = _claude_default_model()
738
+ r["recordActualModel"] = r["defaultModel"] == "default"
739
+ r["loggedIn"] = _claude_logged_in()
740
+ # Claude Code 没有不调模型就能列出可用模型的办法:简称与完整名都照它的配置收下
741
+ if r["loggedIn"]:
742
+ r["available"] = True
743
+
744
+
745
+ def _probe_codex(r: dict) -> None:
746
+ r["defaultModel"] = _codex_default_model()
747
+ code, _ = _probe_cmd(["codex", "login", "status"])
748
+ r["loggedIn"] = _logged_in_by_exit(code)
749
+ if not r["loggedIn"] or r["defaultModel"] is None:
750
+ return
751
+ r["available"] = r["defaultModel"] in _codex_models()
752
+
753
+
754
+ def _codex_models() -> set:
755
+ cache = _read_json(_codex_home() / "models_cache.json")
756
+ models = cache.get("models") if isinstance(cache, dict) else None
757
+ return {m.get("slug") for m in models if isinstance(m, dict)} if isinstance(models, list) else set()
758
+
759
+
760
+ def _pi_logged_in(provider: str) -> bool:
761
+ # --no-refresh:不刷新过期的登录(会写凭据文件);绝不加 --credentials
762
+ code, _ = _probe_cmd(["pi", "auth", "check", "--provider", provider, "--json", "--no-refresh"])
763
+ return _logged_in_by_exit(code)
764
+
765
+
766
+ def _pi_models() -> set:
767
+ """`pi --list-models` 的表格 → {"<渠道>/<模型>"}。"""
768
+ code, table = _probe_cmd(["pi", "--offline", "--no-extensions", "--list-models"])
769
+ if code != 0:
770
+ raise ProbeFailed("probe-failed")
771
+ rows = [line.split() for line in table.splitlines()[1:]]
772
+ return {f"{row[0]}/{row[1]}" for row in rows if len(row) >= 2}
773
+
774
+
775
+ def _probe_pi(r: dict) -> None:
776
+ settings = _read_json(Path.home() / ".pi/agent/settings.json")
777
+ provider = settings.get("defaultProvider") if isinstance(settings, dict) else None
778
+ model = settings.get("defaultModel") if isinstance(settings, dict) else None
779
+ if not isinstance(provider, str) or not isinstance(model, str) or not provider or not model:
780
+ return
781
+ r["defaultModel"] = f"{provider}/{model}"
782
+ r["loggedIn"] = _pi_logged_in(provider)
783
+ if not r["loggedIn"]:
784
+ return
785
+ r["available"] = r["defaultModel"] in _pi_models()
786
+
787
+
788
+ def _omp_default_model() -> str | None:
789
+ try:
790
+ text = (Path.home() / ".omp/agent/config.yml").read_text(encoding="utf-8")
791
+ except OSError:
792
+ return None
793
+ in_roles = False
794
+ for line in text.splitlines():
795
+ if not line.strip() or line.lstrip().startswith("#"):
796
+ continue
797
+ if not line[0].isspace():
798
+ in_roles = line.split("#", 1)[0].strip() == "modelRoles:"
799
+ continue
800
+ m = re.match(r"""\s+default\s*:\s*["']?([^"'#\s]+)""", line)
801
+ if in_roles and m:
802
+ return m.group(1)
803
+ return None
804
+
805
+
806
+ def _omp_quota_reached(provider: str) -> bool:
807
+ """`omp usage --redact --json`:这个渠道的订阅账号全都到了上限才算用完。
808
+
809
+ 只覆盖有额度接口的订阅账号,填 API key 的渠道查不到;这条命令本身出错时只是查不到额度,
810
+ 不拦这个复查员(Claude Code、pi 本来就查不到额度)。邮箱与账号 id 不读、不输出。
811
+ """
812
+ try:
813
+ _, usage = _probe_json(["omp", "usage", "--redact", "--json", "--no-extensions"])
814
+ except ProbeFailed:
815
+ return False
816
+ reports = usage.get("reports")
817
+ mine = [x for x in reports if isinstance(x, dict) and x.get("provider") == provider] if isinstance(reports, list) else []
818
+ return bool(mine) and all((x.get("metadata") or {}).get("limitReached") is True for x in mine)
819
+
820
+
821
+ def _probe_omp(r: dict) -> None:
822
+ configured = _omp_default_model()
823
+ if configured is None:
824
+ return
825
+ model, _, suffix = configured.rpartition(":")
826
+ if model and suffix in OMP_THINKING_SUFFIXES:
827
+ r["defaultEffort"] = None if suffix == "auto" else suffix
828
+ else:
829
+ model = configured
830
+ r["defaultModel"] = model
831
+ selectors = _omp_models()
832
+ provider = model.split("/", 1)[0]
833
+ r["loggedIn"] = _omp_provider_listed(selectors, provider)
834
+ if not r["loggedIn"]:
835
+ return
836
+ r["available"] = model in selectors
837
+ if r["available"] and _omp_quota_reached(provider):
838
+ r["reason"] = "quota-exhausted"
839
+
840
+
841
+ def _omp_models() -> set:
842
+ # 不用 omp auth / omp token(会吐密钥);models --json 只列有凭据的渠道的模型
843
+ _, listed = _probe_json(["omp", "models", "--json", "--no-extensions"])
844
+ models = listed.get("models")
845
+ return {m.get("selector") for m in models if isinstance(m, dict)} if isinstance(models, list) else set()
846
+
847
+
848
+ def _omp_provider_listed(selectors: set, provider: str) -> bool:
849
+ return any(isinstance(s, str) and s.split("/", 1)[0] == provider for s in selectors)
850
+
851
+
852
+ PROBES = {"claude": _probe_claude, "codex": _probe_codex, "pi": _probe_pi, "omp": _probe_omp}
853
+
854
+
855
+ def probe_tool(tool: str) -> dict:
856
+ """查一个工具:装没装、登没登录、配置的默认模型与思考强度、在不在可用列表里、是哪家。"""
857
+ r: dict = {
858
+ "tool": tool,
859
+ "installed": shutil.which(tool) is not None,
860
+ "loggedIn": None,
861
+ "defaultModel": None,
862
+ "defaultEffort": None,
863
+ "available": None,
864
+ "families": [],
865
+ "recordActualModel": False,
866
+ "reason": None,
867
+ }
868
+ if not r["installed"]:
869
+ r["reason"] = "not-installed"
870
+ return r
871
+ try:
872
+ PROBES[tool](r)
873
+ except ProbeFailed as e:
874
+ r["reason"] = e.reason
875
+ if r["defaultModel"] is not None:
876
+ r["families"] = families_of(r["defaultModel"], tool)
877
+ if r["reason"] is not None:
878
+ return r
879
+ if r["defaultModel"] is None:
880
+ r["reason"] = "no-default-model"
881
+ elif not r["loggedIn"]:
882
+ r["reason"] = "not-logged-in"
883
+ elif not r["available"]:
884
+ r["reason"] = "default-model-unavailable"
885
+ return r
886
+
887
+
888
+ def _model_reason(tool: str, model: str) -> str | None:
889
+ """点名的模型能不能用:登录了、在该工具的可用列表里;能用给 None。Claude Code 没有列表,登录了就收下。"""
890
+ if tool == "claude":
891
+ return None if _claude_logged_in() else "not-logged-in"
892
+ if tool == "codex":
893
+ code, _ = _probe_cmd(["codex", "login", "status"])
894
+ if not _logged_in_by_exit(code):
895
+ return "not-logged-in"
896
+ return None if model in _codex_models() else "model-unavailable"
897
+ provider, slash, _ = model.partition("/")
898
+ if not slash:
899
+ return "model-unavailable" # pi、omp 的模型要写成 <渠道>/<模型>
900
+ if tool == "pi":
901
+ if not _pi_logged_in(provider):
902
+ return "not-logged-in"
903
+ return None if model in _pi_models() else "model-unavailable"
904
+ selectors = _omp_models()
905
+ if not _omp_provider_listed(selectors, provider):
906
+ return "not-logged-in"
907
+ if model not in selectors:
908
+ return "model-unavailable"
909
+ return "quota-exhausted" if _omp_quota_reached(provider) else None
910
+
911
+
912
+ def verify_reviewer(w: dict) -> dict:
913
+ """写入或这次用之前再验证一遍:工具装了、登录了、模型(不写时是配置的默认模型)能用。"""
914
+ out = {
915
+ "tool": w["tool"],
916
+ "model": w["model"],
917
+ "effort": w["effort"],
918
+ "checkedModel": w["model"],
919
+ "listChecked": w["tool"] != "claude",
920
+ "usable": False,
921
+ "reason": None,
922
+ }
923
+ if w["model"] is None:
924
+ p = probe_tool(w["tool"])
925
+ out["checkedModel"], out["reason"] = p["defaultModel"], p["reason"]
926
+ elif shutil.which(w["tool"]) is None:
927
+ out["reason"] = "not-installed"
928
+ else:
929
+ try:
930
+ out["reason"] = _model_reason(w["tool"], w["model"])
931
+ except ProbeFailed as e:
932
+ out["reason"] = e.reason
933
+ out["usable"] = out["reason"] is None
934
+ return out
935
+
936
+
937
+ def _settings_line(w: dict) -> str:
938
+ return " ".join(
939
+ [w["tool"]] + [f"{k}={w[k]}" for k in ("model", "effort") if w[k] is not None]
940
+ )
941
+
942
+
943
+ def _kept_from_existing(path: Path) -> tuple[list[str], dict[tuple, list[str]]]:
944
+ """重写前读旧文件:更新的 ATW 写的不降级;它加的顶层键原样留下,复查员行里不认识的字段按(工具, 模型)留下。"""
945
+ try:
946
+ text = path.read_text(encoding="utf-8")
947
+ except FileNotFoundError:
948
+ return [], {}
949
+ except OSError as e:
950
+ raise ReviewError(f"读不了复查员设置 {path}:{e}") from e
951
+ data = _parse_settings_yaml(text, path, [])
952
+ version = data.get("version")
953
+ # 只拦更新的版本;旧文件写坏了(缺版本号等)正好由这次重写修好
954
+ if isinstance(version, str) and version.isdigit() and int(version) > SETTINGS_VERSION:
955
+ raise ReviewError(f"复查员设置 {path} 不对:版本 {version} 比这个 ATW 认得的 {SETTINGS_VERSION} 新:请升级 ATW")
956
+ blocks: list[str] = []
957
+ keep = False
958
+ for line in text.splitlines():
959
+ if line and not line[0].isspace() and not line.startswith("#"):
960
+ keep = line.partition(":")[0].strip() not in ("version", "reviewers")
961
+ if keep and line.strip():
962
+ blocks.append(line)
963
+ extras: dict[tuple, list[str]] = {}
964
+ lines = data.get("reviewers")
965
+ for spec in lines if isinstance(lines, list) else []:
966
+ parts = spec.split()
967
+ if not parts:
968
+ continue
969
+ model = None
970
+ unknown: list[str] = []
971
+ for part in parts[1:]:
972
+ key, sep, value = part.partition("=")
973
+ if sep and value and key in ("model", "effort"):
974
+ model = value if key == "model" else model
975
+ else:
976
+ unknown.append(part)
977
+ extras.setdefault((parts[0], model), unknown)
978
+ return blocks, extras
979
+
980
+
981
+ def cmd_save(args: argparse.Namespace) -> dict:
982
+ wanted = [_parse_wanted(s) for s in args.reviewer]
983
+ path = settings_path()
984
+ blocks, extras = _kept_from_existing(path)
985
+ checked = [verify_reviewer(w) for w in wanted]
986
+ saved = all(c["usable"] for c in checked)
987
+ if saved:
988
+ body = f"version: {SETTINGS_VERSION}\nreviewers:\n" + "".join(
989
+ " - " + " ".join([_settings_line(w), *extras.get((w["tool"], w["model"]), [])]) + "\n"
990
+ for w in wanted
991
+ ) + "".join(f"{line}\n" for line in blocks)
992
+ tmp = path.with_name(path.name + ".tmp")
993
+ try:
994
+ path.parent.mkdir(parents=True, exist_ok=True)
995
+ tmp.write_text(body, encoding="utf-8")
996
+ os.replace(tmp, path)
997
+ except OSError as e:
998
+ with contextlib.suppress(OSError):
999
+ tmp.unlink(missing_ok=True)
1000
+ raise ReviewError(f"写不了复查员设置 {path}:{e}") from e
1001
+ return {"saved": saved, "file": str(path), "reviewers": checked}
1002
+
1003
+
1004
+ # ---------------------------------------------------------------------------
1005
+ # 宿主与当前 AI(prd G)、挑人(prd H)
1006
+ # ---------------------------------------------------------------------------
1007
+
1008
+ NEEDS_USER_CURRENT_AI = "current-ai-unknown"
1009
+
1010
+
1011
+ def host_candidates() -> list[str]:
1012
+ """环境变量里各宿主的标记;一个宿主里再开另一个时,外层的标记会被继承下来。"""
1013
+ env = os.environ
1014
+ agent = env.get("AI_AGENT", "")
1015
+ found: list[str] = []
1016
+ if env.get("OMPCODE"):
1017
+ found.append("omp")
1018
+ if agent == "pi" or env.get("PI_CODING_AGENT"):
1019
+ found.append("pi")
1020
+ if env.get("CODEX_THREAD_ID"):
1021
+ found.append("codex")
1022
+ # omp 也给子进程设 CLAUDECODE=1:有 OMPCODE 时只凭 AI_AGENT 认 Claude Code
1023
+ if agent.startswith("claude-code") or (env.get("CLAUDECODE") and "omp" not in found):
1024
+ found.append("claude")
1025
+ # pi 与 Claude Code 都会改写 AI_AGENT,它指向的是里层那个
1026
+ if "pi" in found and "claude" in found:
1027
+ found.remove("claude" if agent == "pi" else "pi")
1028
+ return found
1029
+
1030
+
1031
+ def detect_host() -> tuple[str | None, list[str]]:
1032
+ """认得出唯一的宿主时给它;标记互相矛盾、分不出里外层时给 None(停下问用户)。"""
1033
+ found = host_candidates()
1034
+ return (found[0] if len(found) == 1 else None), found
1035
+
1036
+
1037
+ # ---------------------------------------------------------------------------
1038
+ # 宿主的围栏(prd L)
1039
+ # ---------------------------------------------------------------------------
1040
+
1041
+
1042
+ def _codex_config_approval() -> tuple[str, str] | None:
1043
+ """Codex 配置里顶层或当前 profile 的 approval_policy 与配置文件路径;没写时给 None。"""
1044
+ path = _codex_home() / "config.toml"
1045
+ try:
1046
+ text = path.read_text(encoding="utf-8")
1047
+ except OSError:
1048
+ return None
1049
+ section, values = "", {}
1050
+ for line in text.splitlines():
1051
+ head = re.match(r"\s*\[([^\]]+)\]\s*$", line)
1052
+ if head:
1053
+ section = head.group(1).strip()
1054
+ continue
1055
+ m = re.match(r"""\s*(approval_policy|profile)\s*=\s*["']([^"']+)["']""", line)
1056
+ if m:
1057
+ values[(section, m.group(1))] = m.group(2)
1058
+ profile = values.get(("", "profile"))
1059
+ policy = values.get((f"profiles.{profile}", "approval_policy")) if profile else None
1060
+ policy = policy or values.get(("", "approval_policy"))
1061
+ return (policy, str(path)) if policy else None
1062
+
1063
+
1064
+ def _codex_approval_never() -> str | None:
1065
+ """Codex 配置里顶层或当前 profile 的 approval_policy 是 never 时给出配置文件路径。"""
1066
+ found = _codex_config_approval()
1067
+ return found[1] if found and found[0] == "never" else None
1068
+
1069
+
1070
+ CLAUDE_MANAGED_SETTINGS = (
1071
+ Path("/Library/Application Support/ClaudeCode/managed-settings.json"),
1072
+ Path("/etc/claude-code/managed-settings.json"),
1073
+ )
1074
+
1075
+
1076
+ def _claude_settings_files() -> tuple[Path, ...]:
1077
+ """Claude Code 的设置文件,按优先级 托管 > 本地 > 项目 > 用户;用户的在最后。"""
1078
+ user_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
1079
+ return (
1080
+ *CLAUDE_MANAGED_SETTINGS,
1081
+ Path.cwd() / ".claude/settings.local.json",
1082
+ Path.cwd() / ".claude/settings.json",
1083
+ user_dir / "settings.json",
1084
+ )
1085
+
1086
+
1087
+ def _claude_retry_allowed() -> tuple[bool, str | None]:
1088
+ """Claude Code 的 sandbox.allowUnsandboxedCommands 实际取值与定下它的设置文件。
1089
+
1090
+ 优先级 托管 > 本地 > 项目 > 用户;但托管或用户设置里的 false 怎样都作数,
1091
+ 项目的 true 改不回来(官方文档 sandboxing「Turn off the retry with strict sandbox mode」)。
1092
+ """
1093
+ ranked = _claude_settings_files()
1094
+ user = ranked[-1]
1095
+ values: list[tuple[Path, bool]] = []
1096
+ for f in ranked:
1097
+ settings = _read_json(f)
1098
+ box = settings.get("sandbox") if isinstance(settings, dict) else None
1099
+ value = box.get("allowUnsandboxedCommands") if isinstance(box, dict) else None
1100
+ if isinstance(value, bool):
1101
+ values.append((f, value))
1102
+ for f, value in values:
1103
+ if value is False and (f in CLAUDE_MANAGED_SETTINGS or f == user):
1104
+ return False, str(f)
1105
+ if values:
1106
+ return values[0][1], str(values[0][0])
1107
+ return True, None
1108
+
1109
+
1110
+ def _claude_permission_mode() -> tuple[str, str] | None:
1111
+ """设置里实际生效的 permissions.defaultMode 与定下它的文件;启动时的开关看不到。"""
1112
+ for f in _claude_settings_files():
1113
+ settings = _read_json(f)
1114
+ perms = settings.get("permissions") if isinstance(settings, dict) else None
1115
+ mode = perms.get("defaultMode") if isinstance(perms, dict) else None
1116
+ if isinstance(mode, str) and mode:
1117
+ return mode, str(f)
1118
+ return None
1119
+
1120
+
1121
+ def _can_write_git() -> bool:
1122
+ """这条命令能不能在当前仓库的 .git 里建文件(冻结要写快照与引用);不在仓库里算不能。"""
1123
+ try:
1124
+ git_dir = Path(_git(Path.cwd(), ["rev-parse", "--path-format=absolute", "--git-common-dir"]).strip())
1125
+ fd, tmp = tempfile.mkstemp(prefix=".atw-host-", dir=git_dir)
1126
+ except (ReviewError, OSError):
1127
+ return False
1128
+ os.close(fd)
1129
+ os.unlink(tmp)
1130
+ return True
1131
+
1132
+
1133
+ def _codex_fenced() -> bool:
1134
+ """Codex 的围栏给命令设 CODEX_SANDBOX;申请获批在围栏外跑的命令(codex 0.163.0 macOS 实测)
1135
+ 不再带 CODEX_SANDBOX,却仍带 CODEX_SANDBOX_NETWORK_DISABLED=1,实际能联网、能写 .git。
1136
+
1137
+ 只剩网络变量时要冻结(写 .git)与复查员联网两样都有证据才算出了围栏:.git 当场试写;
1138
+ 网络只认实测过的 macOS——不在本地探测连通性,免得走代理的电脑被误判、也不多等超时。
1139
+ 别的平台(Linux 上可能本就没有 CODEX_SANDBOX)按仍在围栏里,宿主照 skill 在冻结之前停下。
1140
+ """
1141
+ if os.environ.get("CODEX_SANDBOX"):
1142
+ return True
1143
+ if not os.environ.get("CODEX_SANDBOX_NETWORK_DISABLED"):
1144
+ return False
1145
+ return sys.platform != "darwin" or not _can_write_git()
1146
+
1147
+
1148
+ def host_sandbox() -> dict:
1149
+ """主 AI 跑的命令是不是在宿主的围栏里、能不能申请到围栏外跑。
1150
+
1151
+ 只认这条命令自己:在围栏外重跑一次(申请获批后)就不再算在围栏里。
1152
+ 能否申请按实际生效的设置;只看得到配置文件时给 unknown,以那次申请本身为准。
1153
+ """
1154
+ env = os.environ
1155
+ found: list[dict] = []
1156
+ if _codex_fenced():
1157
+ # 会话记录里的 approval_policy 是实际生效的(含 --approve-for-me、-a、profile 等启动时的覆盖);
1158
+ # 读不到时配置文件的 never 可能已被启动开关盖过,只能说不确定、先申请一次
1159
+ effective = _codex_session_value(env.get("CODEX_THREAD_ID", ""), "approval_policy")
1160
+ if effective:
1161
+ can, source = ("no" if effective[0] == "never" else "yes"), effective[1]
1162
+ else:
1163
+ source = _codex_approval_never()
1164
+ can = "unknown" if source else "yes"
1165
+ found.append({
1166
+ "by": "codex",
1167
+ "networkDisabled": env.get("CODEX_SANDBOX_NETWORK_DISABLED") == "1",
1168
+ "canRequest": can,
1169
+ "requestWith": "require_escalated",
1170
+ "source": source,
1171
+ })
1172
+ # Claude Code 给围栏里跑的每条命令设 SANDBOX_RUNTIME=1(@anthropic-ai/sandbox-runtime)
1173
+ if env.get("SANDBOX_RUNTIME") == "1":
1174
+ allowed, source = _claude_retry_allowed()
1175
+ found.append({
1176
+ "by": "claude",
1177
+ "networkDisabled": None,
1178
+ "canRequest": "yes" if allowed else "no",
1179
+ "requestWith": "dangerouslyDisableSandbox",
1180
+ "source": source,
1181
+ })
1182
+ candidates = host_candidates()
1183
+ return {
1184
+ "host": candidates[0] if len(candidates) == 1 else None,
1185
+ "hostCandidates": candidates,
1186
+ "sandboxed": bool(found),
1187
+ "canRequest": next((c for c in ("no", "unknown") if any(s["canRequest"] == c for s in found)), "yes") if found else None,
1188
+ "sandboxes": found,
1189
+ }
1190
+
1191
+
1192
+ def _codex_session_value(thread_id: str, key: str) -> tuple[str, str] | None:
1193
+ """本次 Codex 会话记录文件里最后一轮 turn_context 的某一项(实际生效的值,含启动时的覆盖)。"""
1194
+ sessions = _codex_home() / "sessions"
1195
+ if not thread_id or not sessions.is_dir():
1196
+ return None
1197
+ for f in sorted(sessions.rglob(f"rollout-*-{thread_id}.jsonl"), reverse=True):
1198
+ value = None
1199
+ try:
1200
+ lines = f.read_text(encoding="utf-8").splitlines()
1201
+ except OSError:
1202
+ continue
1203
+ for line in lines:
1204
+ try:
1205
+ o = json.loads(line)
1206
+ except ValueError:
1207
+ continue
1208
+ if isinstance(o, dict) and o.get("type") == "turn_context":
1209
+ v = (o.get("payload") or {}).get(key)
1210
+ if isinstance(v, str) and v:
1211
+ value = v
1212
+ if value:
1213
+ return value, str(f)
1214
+ return None
1215
+
1216
+
1217
+ def _codex_session_model(thread_id: str) -> tuple[str, str] | None:
1218
+ """本次 Codex 会话记录文件里最后一轮的 turn_context.model。"""
1219
+ return _codex_session_value(thread_id, "model")
1220
+
1221
+
1222
+ def current_ai(host: str | None, model_line: str | None) -> dict | None:
1223
+ """按宿主查固定来源,不让模型自报;查不出给 None。"""
1224
+ model = source = None
1225
+ if host in ("claude", "omp") and model_line:
1226
+ if host == "claude":
1227
+ # 宿主可能整行照抄,id 后面还跟着别的句子;id 后紧跟的标点不算 id。
1228
+ # 「powered by the model」后要整个 id 带标点结束一句,「Opus 5.5」「named Opus」这类显示名不当 id
1229
+ model_id = r"""([^\s,;.)"]+(?:\.[^\s,;.)"]+)*)"""
1230
+ m = re.search(rf"""exact model ID is {model_id}[,;.)"]*(?:\s|$)""", model_line) or re.search(
1231
+ rf"""powered by the model (?!named\s){model_id}(?:[,;.)"]+(?:\s|$)|$)""", model_line
1232
+ )
1233
+ else:
1234
+ m = re.search(r"Model:\s*(\S+)", model_line)
1235
+ if m:
1236
+ model, source = m.group(1), "system prompt"
1237
+ elif host == "pi":
1238
+ provider, name = os.environ.get("PI_PROVIDER"), os.environ.get("PI_MODEL")
1239
+ if name:
1240
+ model = f"{provider}/{name}" if provider else name
1241
+ source = "PI_PROVIDER/PI_MODEL"
1242
+ elif host == "codex":
1243
+ found = _codex_session_model(os.environ.get("CODEX_THREAD_ID", ""))
1244
+ if found:
1245
+ model, source = found[0], f"Codex session record {found[1]}"
1246
+ if model is None or host is None:
1247
+ return None
1248
+ return {"model": model, "families": families_of(model, host), "source": source}
1249
+
1250
+
1251
+ def _family_named(name: str) -> list[str]:
1252
+ for family in FAMILY_WORDS:
1253
+ if family.lower() == name.strip().lower():
1254
+ return [family]
1255
+ return families_of(name)
1256
+
1257
+
1258
+ def _parse_wanted(spec: str) -> dict:
1259
+ """当场点名:`<工具> [model=<模型>] [effort=<强度>]`,模型可省(用查实的默认模型)。"""
1260
+ parts = spec.split()
1261
+ if not parts or parts[0] not in TOOLS:
1262
+ raise ReviewError(f"复查员「{spec}」要以工具名开头:{' / '.join(TOOLS)}")
1263
+ fields: dict[str, str] = {}
1264
+ for part in parts[1:]:
1265
+ key, sep, value = part.partition("=")
1266
+ if not sep or not value or key not in ("model", "effort"):
1267
+ raise ReviewError(f"复查员「{spec}」里的「{part}」不认识,只认 model= 与 effort=")
1268
+ fields[key] = value
1269
+ _check_effort(spec, parts[0], fields.get("effort"))
1270
+ return {"tool": parts[0], "model": fields.get("model"), "effort": fields.get("effort")}
1271
+
1272
+
1273
+ def _candidate(tool: str, model: str, effort: str | None, avoid: list[str] | None, auto: bool,
1274
+ record_actual: bool) -> dict:
1275
+ fams = families_of(model, tool)
1276
+ return {
1277
+ "tool": tool,
1278
+ "model": model,
1279
+ "effort": effort,
1280
+ "families": fams,
1281
+ # 认不出家或不知道要避开谁时为 None
1282
+ "sameFamily": bool(set(fams) & set(avoid)) if fams and avoid is not None else None,
1283
+ "unrecognised": not fams,
1284
+ "needsNod": auto,
1285
+ # Claude Code 复查员锁在仓库里;其余三个技术上能读到用户能读的文件
1286
+ "readsWholeDisk": tool != "claude",
1287
+ "recordActualModel": record_actual,
1288
+ }
1289
+
1290
+
1291
+ def _who_wrote(args: argparse.Namespace) -> tuple[dict, list[str] | None]:
1292
+ """判宿主、当前 AI 与作者;给出输出的公共部分与要避开的家(不知道时为 None)。"""
1293
+ host, candidates = detect_host()
1294
+ current = current_ai(host, args.model_line)
1295
+ if host in ("claude", "omp") and args.model_line is not None and current is None:
1296
+ # 传错了那一行(只给模型编号、只抄半句等)当场退出 2,不往下给「认不出哪家 AI」的结果
1297
+ raise ReviewError(
1298
+ f"认不出 --model-line「{args.model_line}」里的模型:要原样照抄系统提示里写模型的那一整行——"
1299
+ "Claude Code 是以「You are powered by the model」开头的那一整行(后面若有「The exact model ID is …」一句也照抄),"
1300
+ "omp 是「- Model: <provider>/<id>」那一行"
1301
+ )
1302
+ author: list[str] | None = None
1303
+ if args.author:
1304
+ author = []
1305
+ for name in args.author:
1306
+ fams = _family_named(name)
1307
+ if not fams:
1308
+ raise ReviewError(f"认不出作者「{name}」是哪家:给家名(如 OpenAI)或模型名")
1309
+ author += [f for f in fams if f not in author]
1310
+ if author is not None:
1311
+ avoid: list[str] | None = author
1312
+ elif current is not None and current["families"]:
1313
+ avoid = current["families"]
1314
+ else:
1315
+ avoid = None
1316
+ return {
1317
+ "host": host,
1318
+ "hostCandidates": candidates,
1319
+ "current": current,
1320
+ "author": author,
1321
+ "avoid": avoid or [],
1322
+ "needsUser": NEEDS_USER_CURRENT_AI if avoid is None else None,
1323
+ }, avoid
1324
+
1325
+
1326
+ def _auto_skip_reason(p: dict, avoid: list[str]) -> str | None:
1327
+ """自动挑的合格条件:能用、认得出家、与作者不同家。"""
1328
+ if p["reason"] is not None:
1329
+ return p["reason"]
1330
+ if not p["families"]:
1331
+ return "unrecognised-family"
1332
+ if set(p["families"]) & set(avoid):
1333
+ return "same-family"
1334
+ return None
1335
+
1336
+
1337
+ def cmd_wizard(args: argparse.Namespace) -> dict:
1338
+ out, avoid = _who_wrote(args)
1339
+ tools = []
1340
+ recommended = None
1341
+ for tool in AUTO_PICK_ORDER:
1342
+ p = probe_tool(tool)
1343
+ same = bool(set(p["families"]) & set(avoid)) if p["families"] and avoid is not None else None
1344
+ tools.append({**p, "usable": p["reason"] is None, "sameFamily": same, "readsWholeDisk": tool != "claude"})
1345
+ if recommended is None and avoid is not None and _auto_skip_reason(p, avoid) is None:
1346
+ recommended = tool
1347
+ return {**out, "tools": tools, "recommended": recommended}
1348
+
1349
+
1350
+ def _settled(w: dict, avoid: list[str] | None) -> tuple[dict | None, str | None]:
1351
+ """点名或设置里的一个复查员:写了模型照收、不先查;没写模型时查默认模型,用不了给原因。"""
1352
+ if w["model"] is not None:
1353
+ return _candidate(w["tool"], w["model"], w["effort"], avoid, False, False), None
1354
+ p = probe_tool(w["tool"])
1355
+ if p["reason"] is not None:
1356
+ return None, p["reason"]
1357
+ return _candidate(
1358
+ w["tool"], p["defaultModel"], w["effort"] or p["defaultEffort"], avoid, False, p["recordActualModel"]
1359
+ ), None
1360
+
1361
+
1362
+ def _auto_pick(avoid: list[str], exclude: set[str]) -> tuple[dict | None, list[dict]]:
1363
+ """只挑一个:按顺序找第一个合格的,不先试跑;exclude 里的工具(这次跑不起来的)不再去查。"""
1364
+ skipped: list[dict] = []
1365
+ for tool in AUTO_PICK_ORDER:
1366
+ if tool in exclude:
1367
+ skipped.append({"tool": tool, "reason": "failed"})
1368
+ continue
1369
+ p = probe_tool(tool)
1370
+ reason = _auto_skip_reason(p, avoid)
1371
+ if reason is not None:
1372
+ skipped.append({"tool": tool, "reason": reason})
1373
+ continue
1374
+ return _candidate(tool, p["defaultModel"], p["defaultEffort"], avoid, True, p["recordActualModel"]), skipped
1375
+ return None, skipped
1376
+
1377
+
1378
+ def _chosen_failed(out: dict, avoid: list[str] | None, failed: list[str]) -> dict:
1379
+ """点名或设置里的跑不起来:不自动换人,只给出问「换成自动挑出的某某」时的那一个。"""
1380
+ if failed and avoid is not None:
1381
+ running = {r["tool"] for r in out["reviewers"]}
1382
+ alt, _ = _auto_pick(avoid, set(failed) | running)
1383
+ else:
1384
+ alt = None
1385
+ out["failed"] = [{"tool": t, "from": out["mode"], "ask": "retry-switch-skip", "next": alt} for t in failed]
1386
+ return out
1387
+
1388
+
1389
+ def _from_settings(reviewers: list[dict], avoid: list[str] | None, out: dict) -> int:
1390
+ """设置里的复查员逐个定下:同家的与跑不起来的进 skipped(不换人);返回因同家跳过的个数。"""
1391
+ same_family = 0
1392
+ for w in reviewers:
1393
+ fams = families_of(w["model"], w["tool"]) if w["model"] is not None else None
1394
+ c, reason = (None, "same-family") if fams and avoid and set(fams) & set(avoid) else _settled(w, avoid)
1395
+ # 省略模型的查完默认模型才知道是哪家
1396
+ if c is not None and c["sameFamily"]:
1397
+ c, reason = None, "same-family"
1398
+ if c is None:
1399
+ same_family += reason == "same-family"
1400
+ out["skipped"].append({"tool": w["tool"], "model": w["model"], "reason": reason, "from": "settings"})
1401
+ else:
1402
+ out["reviewers"].append(c)
1403
+ return same_family
1404
+
1405
+
1406
+ def _pick_settings_only(out: dict, avoid: list[str] | None) -> dict:
1407
+ """无人值守时(prd K 节):只用设置里的,不自动挑、不换人、不弹向导;用不成时给原因,不退出 2。"""
1408
+ out["mode"] = "settings-only"
1409
+ out["unavailable"] = None
1410
+ # 日志复查没做成时,清单条目的 --command 直接取它(工单 22);认不出宿主时为 None
1411
+ zh, en = (skill_input(f"atw-cross-model-review {w}", out["host"]) for w in ENTRY_WORDS["log-review"])
1412
+ out["logReviewCommand"] = None if zh is None else {"zh": zh, "en": en}
1413
+
1414
+ def unavailable(reason: str, detail: str | None = None) -> dict:
1415
+ out["unavailable"] = {"reason": reason, "detail": detail}
1416
+ return out
1417
+
1418
+ try:
1419
+ settings = read_settings()
1420
+ except ReviewError as e:
1421
+ out["settings"] = {"file": str(settings_path()), "warnings": []}
1422
+ return unavailable("settings-broken", str(e))
1423
+ out["settings"] = {"file": settings["file"], "warnings": settings["warnings"]}
1424
+ if not settings["reviewers"]:
1425
+ return unavailable("no-settings")
1426
+ if avoid is None:
1427
+ # 不知道要避开哪家就得问用户,而这时没人能问
1428
+ return unavailable(NEEDS_USER_CURRENT_AI)
1429
+ box = host_sandbox()
1430
+ if box["sandboxed"]:
1431
+ # 复查员在宿主围栏里连不上模型,出围栏要用户点头(canRequest 为 no 时连申请都不行)
1432
+ fences = "、".join(f"{b['by']}(canRequest {b['canRequest']})" for b in box["sandboxes"])
1433
+ return unavailable("sandbox-needs-approval", f"命令在宿主的围栏里:{fences}")
1434
+ _from_settings(settings["reviewers"], avoid, out)
1435
+ return out if out["reviewers"] else unavailable("none-qualified")
1436
+
1437
+
1438
+ def cmd_pick(args: argparse.Namespace) -> dict:
1439
+ if args.settings_only and (args.reviewer or args.auto or args.failed):
1440
+ raise ReviewError("--settings-only 只用设置里的复查员:不与 --reviewer、--auto、--failed 同用(不点名、不自动挑、不换人)")
1441
+ out, avoid = _who_wrote(args)
1442
+ failed = args.failed or []
1443
+ out.update({"mode": "auto", "settings": None, "reviewers": [], "skipped": [], "failed": []})
1444
+ if args.settings_only:
1445
+ return _pick_settings_only(out, avoid)
1446
+ if args.reviewer:
1447
+ # 当场点名优先:照跑,同家或认不出也照跑(由 AI 开跑前说一句)
1448
+ out["mode"] = "named"
1449
+ for spec in args.reviewer:
1450
+ w = _parse_wanted(spec)
1451
+ c, reason = _settled(w, avoid)
1452
+ if c is None:
1453
+ out["skipped"].append({"tool": w["tool"], "reason": reason})
1454
+ else:
1455
+ out["reviewers"].append(c)
1456
+ return _chosen_failed(out, avoid, failed)
1457
+ if not args.auto:
1458
+ settings = read_settings()
1459
+ out["settings"] = {"file": settings["file"], "warnings": settings["warnings"]}
1460
+ if not settings["reviewers"]:
1461
+ out["mode"] = "setup" # 这台电脑还没设复查员:走设置向导,向导即这次的确认
1462
+ return out
1463
+ out["mode"] = "settings"
1464
+ same_family = _from_settings(settings["reviewers"], avoid, out)
1465
+ # 全因同家被跳过才改自动挑;跑不起来的是用户选定的人,不自动换
1466
+ if same_family < len(settings["reviewers"]):
1467
+ return _chosen_failed(out, avoid, failed)
1468
+ out["mode"] = "auto"
1469
+ if avoid is None:
1470
+ # 不知道要避开哪家就不挑,停下问用户;跑不起来的照样列出
1471
+ out["failed"] = [{"tool": t, "from": "auto", "ask": "nod", "next": None} for t in failed]
1472
+ return out
1473
+ picked, skipped = _auto_pick(avoid, set(failed))
1474
+ out["skipped"] += skipped
1475
+ if picked is not None:
1476
+ out["reviewers"].append(picked)
1477
+ # 自动挑出的跑不起来:推荐下一个合格的,用户点头再换
1478
+ out["failed"] = [{"tool": t, "from": "auto", "ask": "nod", "next": picked} for t in failed]
1479
+ return out
1480
+
1481
+
1482
+ def codex_personal_instructions() -> str | None:
1483
+ """Codex 复查员关不掉的用户个人说明文件;有内容时返回路径。"""
1484
+ home = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
1485
+ for name in ("AGENTS.override.md", "AGENTS.md"):
1486
+ p = home / name
1487
+ if p.is_file() and p.stat().st_size > 0:
1488
+ return str(p)
1489
+ return None
1490
+
1491
+
1492
+ # ---------------------------------------------------------------------------
1493
+ # 材料与题目
1494
+ # ---------------------------------------------------------------------------
1495
+
1496
+
1497
+ def _rel(root: Path, p: Path) -> str:
1498
+ try:
1499
+ return p.resolve().relative_to(root.resolve()).as_posix()
1500
+ except ValueError:
1501
+ return str(p)
1502
+
1503
+
1504
+ def ticket_bodies(issues_dir: Path) -> str:
1505
+ """全部工单正文,去掉末尾的 `## Comments` 一节。"""
1506
+ out: list[str] = []
1507
+ for f in sorted(issues_dir.glob("*.md")):
1508
+ body: list[str] = []
1509
+ for line in f.read_text(encoding="utf-8").splitlines():
1510
+ if line.strip() == "## Comments":
1511
+ break
1512
+ body.append(line)
1513
+ out.append(f"===== {f.name} =====\n" + "\n".join(body).rstrip() + "\n")
1514
+ return "\n".join(out)
1515
+
1516
+
1517
+ def _change_line(scope: dict, patch_rel: str, group: tuple[int, int, list[str]] | None) -> str:
1518
+ base, commit = scope["base"], scope["baseCommit"]
1519
+ if scope["baseFrom"] == "snapshot":
1520
+ what = (
1521
+ f"only what changed after the last review: everything that differs from snapshot `{commit}`"
1522
+ " (the working tree the last review saw), uncommitted edits and new files included."
1523
+ )
1524
+ elif scope["onBase"]:
1525
+ what = f"the uncommitted edits and new files on `{base}`, on top of commit `{commit}`."
1526
+ elif scope["baseFrom"] == "since":
1527
+ what = f"everything since commit `{commit}` (from `{base}`), uncommitted edits and new files included."
1528
+ else:
1529
+ what = (
1530
+ f"everything since commit `{commit}` (where this branch left `{base}`), uncommitted edits and"
1531
+ " new files included."
1532
+ )
1533
+ line = f"- The change under review: `{patch_rel}` — {what}"
1534
+ if group is not None:
1535
+ k, n, members = group
1536
+ listed = ", ".join(f"`{x}`" for x in members)
1537
+ line += f" This is group {k} of {n} ({listed}); the rest of the change is reviewed separately."
1538
+ return line
1539
+
1540
+
1541
+ def build_prompt(
1542
+ root: Path,
1543
+ questions: str,
1544
+ scope: dict,
1545
+ path_only: list[dict],
1546
+ patch_rel: str,
1547
+ group: tuple[int, int, list[str]] | None,
1548
+ task: Path | None,
1549
+ tickets_rel: str | None,
1550
+ intent: str | None,
1551
+ picked_rel: str | None = None,
1552
+ ) -> str:
1553
+ m: list[str] = [
1554
+ "## Material",
1555
+ "",
1556
+ "Everything listed here is on disk under the working directory. Read it with your"
1557
+ " read-only tools. Every reviewer in this round gets exactly the same material.",
1558
+ "",
1559
+ _change_line(scope, patch_rel, group),
1560
+ ]
1561
+ if scope["paths"]:
1562
+ m.append("- Only these paths were asked for: " + ", ".join(f"`{x}`" for x in scope["paths"]) + ".")
1563
+ if path_only:
1564
+ listed = ", ".join(f"`{x['path']}` ({x['reason']})" for x in path_only)
1565
+ m.append(f"- Changed but not in the patch (binary or too large; open them yourself if they matter): {listed}.")
1566
+ if task is not None and (task / "prd.md").is_file():
1567
+ m.append(f"- The spec this change implements: `{_rel(root, task / 'prd.md')}`.")
1568
+ if tickets_rel is not None:
1569
+ m.append(f"- The tickets it was sliced into (bodies only): `{tickets_rel}`.")
1570
+ if picked_rel is not None:
1571
+ m.append(
1572
+ f"- The findings the user picked to fix after the last review, from its report: `{picked_rel}`."
1573
+ )
1574
+ if task is not None and (task / "decision-log.md").is_file():
1575
+ m.append(
1576
+ f"- The author's decision log: `{_rel(root, task / 'decision-log.md')}`. Treat every entry"
1577
+ " as a claim to check, not as fact: does the code really do what it says, for the reason"
1578
+ " it gives, and does the evidence it points to open?"
1579
+ )
1580
+ if intent is not None:
1581
+ m.append("- There is no written spec. What the change is meant to do, as the author put it:")
1582
+ m.append("")
1583
+ m.extend(f" > {line}" if line else " >" for line in intent.strip().splitlines())
1584
+ m.append("")
1585
+ conventions = [_rel(root, p) for p in sorted((root / ".atw/spec").glob("**/index.md"))]
1586
+ conventions += [n for n in ("AGENTS.md", "CLAUDE.md") if (root / n).is_file()]
1587
+ if conventions:
1588
+ m.append(
1589
+ "- The project's written conventions, to read as needed: "
1590
+ + ", ".join(f"`{c}`" for c in conventions)
1591
+ + "."
1592
+ )
1593
+ # prd E:任务目录里给出的之外(调研笔记、决策单、追踪文件、别的任务)都不是材料
1594
+ m.append(
1595
+ "- Nothing else under `.atw/tasks/` is review material: not this task's `research/` notes,"
1596
+ " `map-issues/`, `check.jsonl` or `task.json`, and not other tasks. Do not read it and do"
1597
+ " not use it as evidence."
1598
+ )
1599
+ m.append(
1600
+ "- Apart from that, you may read any other file in the repository to follow a call or check"
1601
+ " a claim. You may not change anything."
1602
+ )
1603
+ return questions.rstrip() + "\n\n---\n\n" + "\n".join(m) + "\n"
1604
+
1605
+
1606
+ # ---------------------------------------------------------------------------
1607
+ # 开跑前交代(prd C 节第 4 项;工单 28):脚本写好,AI 原样贴出
1608
+ # ---------------------------------------------------------------------------
1609
+
1610
+ TOOL_NAMES = {"claude": "Claude Code", "codex": "Codex", "pi": "pi", "omp": "omp"}
1611
+ SKIP_REASONS = {
1612
+ "not-installed": ("没装", "not installed"),
1613
+ "not-logged-in": ("没登录", "not logged in"),
1614
+ "no-default-model": ("没设默认模型", "no default model set"),
1615
+ "model-unavailable": ("模型不在它的可用列表里", "the model is not in its list"),
1616
+ "quota-exhausted": ("额度用完了", "its quota is used up"),
1617
+ "probe-failed": ("检查它时出错了,它本身也许能用", "checking it failed; the tool itself may still work"),
1618
+ "probe-timed-out": ("检查它时没按时回应", "checking it did not answer in time"),
1619
+ "unrecognised-family": ("认不出是哪家", "its family cannot be told"),
1620
+ }
1621
+
1622
+
1623
+ def _who(r: dict) -> str:
1624
+ return f"{TOOL_NAMES[r['tool']]} `{r['model']}`"
1625
+
1626
+
1627
+ def _files(n: int, en: bool) -> str:
1628
+ return (f"{n} file" + ("" if n == 1 else "s")) if en else f"{n} 个文件"
1629
+
1630
+
1631
+ def _lines(n: int, en: bool) -> str:
1632
+ return (f"{n} line" + ("" if n == 1 else "s")) if en else f"{n} 行"
1633
+
1634
+
1635
+ def _ticks(items: list[str], en: bool) -> str:
1636
+ return (", " if en else "、").join(f"`{x}`" for x in items)
1637
+
1638
+
1639
+ def _scope_clause(out: dict, en: bool) -> str:
1640
+ base, c = out["base"], out["baseCommit"][:7]
1641
+ size = (", " if en else ",").join([_files(len(out["files"]), en), _lines(out["totalLines"], en)])
1642
+ if out["baseFrom"] == "snapshot":
1643
+ s = (f"This is a recheck, of only what changed after the last review (against snapshot `{c}`, uncommitted"
1644
+ f" edits and new files included): {size}." if en else
1645
+ f"这是复核,只看上次复查之后改过的地方(与快照 `{c}` 相比,含未提交的修改与新文件):{size}。")
1646
+ elif out["onBase"]:
1647
+ s = (f"Reviewing only the uncommitted edits and new files on `{base}`, on top of commit `{c}`: {size}." if en
1648
+ else f"这次只复查 `{base}` 上未提交的修改与新文件(在提交 `{c}` 之上):{size}。")
1649
+ elif out["baseFrom"] == "since":
1650
+ # 用户给的就是提交号时不再重复一遍
1651
+ given = "" if out["baseCommit"].startswith(base) else (
1652
+ f" (the start point you gave, `{base}`)" if en else f"(你给的起点 `{base}`)")
1653
+ s = (f"Reviewing everything since commit `{c}`{given}, uncommitted edits and new files included: {size}." if en
1654
+ else f"这次复查自提交 `{c}`{given}以来的全部改动,含未提交的修改与新文件:{size}。")
1655
+ else:
1656
+ s = (f"Reviewing everything since this branch left `{base}` (from commit `{c}`, uncommitted edits and new"
1657
+ f" files included): {size}." if en else
1658
+ f"这次复查这条分支离开 `{base}` 以来的全部改动(自提交 `{c}` 起,含未提交的修改与新文件):{size}。")
1659
+ if out["paths"]:
1660
+ s += (f" Only these paths: {_ticks(out['paths'], en)}." if en else f"只看你指定的:{_ticks(out['paths'], en)}。")
1661
+ if out.get("groups"):
1662
+ gs = out["groups"]
1663
+ parts = [
1664
+ (f"group {i} {_ticks(g['paths'], en)}, {_files(len(g['files']), en)}, {_lines(g['totalLines'], en)}" if en
1665
+ else f"第 {i} 组 {_ticks(g['paths'], en)},{_files(len(g['files']), en)},{_lines(g['totalLines'], en)}")
1666
+ for i, g in enumerate(gs, 1)
1667
+ ]
1668
+ s += (f" In {len(gs)} groups, run one after another: {'; '.join(parts)}." if en
1669
+ else f"分 {len(gs)} 组,各跑一次:{';'.join(parts)}。")
1670
+ return s
1671
+
1672
+
1673
+ def _reviewer_part(r: dict, fams: list[str], en: bool) -> str:
1674
+ bits = []
1675
+ if r["effort"]:
1676
+ bits.append(f"effort {r['effort']}" if en else f"思考强度 {r['effort']}")
1677
+ if fams:
1678
+ bits.append(f"{', '.join(fams)}, recognised by name" if en else f"按名字认出是 {'、'.join(fams)} 家")
1679
+ else:
1680
+ bits.append("family cannot be told" if en else "认不出是哪家")
1681
+ return f"{_who(r)} ({', '.join(bits)})" if en else f"{_who(r)}({','.join(bits)})"
1682
+
1683
+
1684
+ def _skip_reason(s: dict, avoid: list[str], en: bool) -> str:
1685
+ if s["reason"] == "same-family":
1686
+ fam = ", ".join(avoid) if en else "、".join(avoid)
1687
+ return f"same family as the AI that wrote the change: {fam}" if en else f"与写这段改动的 AI 同家:{fam}"
1688
+ zh_en = SKIP_REASONS.get(s["reason"])
1689
+ return s["reason"] if zh_en is None else zh_en[1 if en else 0]
1690
+
1691
+
1692
+ def _unrecognised_sentence(r: dict, en: bool) -> str:
1693
+ return (f"{_who(r)}'s family cannot be told, so it may be the same; it runs anyway, so the review counts for less."
1694
+ if en else f"{_who(r)} 认不出是哪家,分不清是不是同家,仍然照跑,这次复查的分量要打折扣。")
1695
+
1696
+
1697
+ def _skipped_line(from_settings: list[dict], avoid: list[str], en: bool) -> str:
1698
+ """设置里这次不跑的人与原因,不带句末标点。"""
1699
+ items = []
1700
+ for s in from_settings:
1701
+ who = TOOL_NAMES[s["tool"]] + (f" `{s['model']}`" if s.get("model") else "")
1702
+ why = _skip_reason(s, avoid, en)
1703
+ items.append(f"{who} ({why})" if en else f"{who}({why})")
1704
+ return (f"From the settings, these sit this round out: {'; '.join(items)}" if en
1705
+ else f"设置里的这几个这次不跑:{';'.join(items)}")
1706
+
1707
+
1708
+ def _check_pick(pick: object) -> None:
1709
+ """冻结之前查 --pick 里交代要用的字段,免得写到一半才出错、留下快照引用。"""
1710
+ bad = "--pick 要原样给 pick 输出的那个 JSON 对象"
1711
+ if not isinstance(pick, dict):
1712
+ raise ReviewError(bad)
1713
+ if not isinstance(pick.get("mode"), (str, type(None))):
1714
+ raise ReviewError(f"{bad}:mode 不对")
1715
+ avoid = pick.get("avoid")
1716
+ if avoid is not None and not (isinstance(avoid, list) and all(isinstance(x, str) for x in avoid)):
1717
+ raise ReviewError(f"{bad}:avoid 要是家名的列表")
1718
+ for key in ("reviewers", "skipped"):
1719
+ items = pick.get(key)
1720
+ if items is None:
1721
+ continue
1722
+ if not isinstance(items, list) or not all(isinstance(x, dict) for x in items):
1723
+ raise ReviewError(f"{bad}:{key} 要是对象的列表")
1724
+ if key == "skipped" and not all(x.get("tool") in TOOLS and isinstance(x.get("reason"), str) for x in items):
1725
+ raise ReviewError(f"{bad}:skipped 每条要有 tool 与 reason")
1726
+
1727
+
1728
+ READS_CODE = (
1729
+ "复查员可以只读地翻看仓库里的其他文件(任务记录里材料以外的不算),每个复查员最多 30 分钟。",
1730
+ "Reviewers may read other files in the repository, read-only (task records outside the material"
1731
+ " excepted), and each gets at most 30 minutes.",
1732
+ )
1733
+
1734
+
1735
+ def build_notice(
1736
+ out: dict,
1737
+ pick: dict | None,
1738
+ scope: Callable[[dict, bool], str] = _scope_clause,
1739
+ reads: tuple[str, str] = READS_CODE,
1740
+ ) -> dict:
1741
+ """按 prepare 的结果与 pick 的输出写好开跑前那段交代(中英各一份),只列用得上的项。
1742
+
1743
+ 日志复查换掉开头讲范围的那句(scope)与讲复查员能读什么的那句(reads),其余照用。
1744
+ """
1745
+ pick = pick or {}
1746
+ mode = pick.get("mode")
1747
+ avoid = pick.get("avoid") or []
1748
+ named = {(r.get("tool"), r.get("model")) for r in pick.get("reviewers") or []} if mode == "named" else set()
1749
+ from_settings = [s for s in pick.get("skipped") or [] if s.get("from") == "settings"]
1750
+ notice = {}
1751
+ for lang in ("zh", "en"):
1752
+ en = lang == "en"
1753
+ sent = [scope(out, en)]
1754
+ rs = out["reviewers"]
1755
+ fams = [families_of(r["model"], r["tool"]) for r in rs]
1756
+ listed = [_reviewer_part(r, f, en) for r, f in zip(rs, fams)]
1757
+ if len(rs) == 1:
1758
+ sent.append(f"It goes to {listed[0]}." if en else f"交给 {listed[0]}。")
1759
+ else:
1760
+ sent.append(f"It goes to {len(rs)} reviewers, at the same time on the same material: {'; '.join(listed)}."
1761
+ if en else f"交给 {len(rs)} 个复查员,同时跑、材料相同:{';'.join(listed)}。")
1762
+ for r, f in zip(rs, fams):
1763
+ if not f:
1764
+ sent.append(_unrecognised_sentence(r, en))
1765
+ elif set(f) & set(avoid):
1766
+ same = ", ".join(x for x in f if x in avoid) if en else "、".join(x for x in f if x in avoid)
1767
+ sent.append(f"{_who(r)} is of the same family as the AI that wrote the change ({same}); it runs anyway,"
1768
+ " so the review counts for less." if en else
1769
+ f"{_who(r)} 与写这段改动的 AI 同家({same}),仍然照跑,这次复查的分量要打折扣。")
1770
+ if from_settings:
1771
+ line = _skipped_line(from_settings, avoid, en)
1772
+ if mode == "auto":
1773
+ line += ", so the reviewer this time was picked automatically" if en else ",所以这次用自动挑出的复查员"
1774
+ sent.append(line + ("." if en else "。"))
1775
+ # prd F 节:当场点名的在这段话里提醒一次;设置里的、点头同意的已在认可时说过,复核沿用第一次的人也不再说
1776
+ recheck = out.get("baseFrom") == "snapshot"
1777
+ disk = [_who(r) for r in rs if r["tool"] != "claude" and (r["tool"], r["model"]) in named and not recheck]
1778
+ if disk:
1779
+ sent.append(f"{' and '.join(disk)} can technically read any file on this computer that you can read." if en
1780
+ else f"{'、'.join(disk)} 技术上能读到这台电脑上你能读的任何文件。")
1781
+ secret_like = out.get("skippedSecretLike") or []
1782
+ if secret_like:
1783
+ names = " and ".join(f"`{x}`" for x in secret_like) if en else _ticks(secret_like, en)
1784
+ sent.append(f"{names} {'is' if len(secret_like) == 1 else 'are'} left out because"
1785
+ f" {'its name looks' if len(secret_like) == 1 else 'their names look'} like"
1786
+ " secrets." if en else f"{names} 名字像密钥,没放进去。")
1787
+ for r in rs:
1788
+ if r.get("personalInstructions"):
1789
+ p = r["personalInstructions"]
1790
+ sent.append(f"{_who(r)} will read your personal instructions at `{p}` (Codex has no switch to turn"
1791
+ " them off)." if en else f"{_who(r)} 会读你的个人说明 `{p}`(Codex 没有开关能关掉)。")
1792
+ sent.append(reads[1] if en else reads[0])
1793
+ sent.append("Interrupt now to change the scope." if en else "要改范围,现在打断我。")
1794
+ notice[lang] = (" " if en else "").join(sent)
1795
+ return notice
1796
+
1797
+
1798
+ # ---------------------------------------------------------------------------
1799
+ # 无人值守的开跑提醒(prd F 节;工单 20):脚本写好全文,两个连做 skill 原样贴出
1800
+ # ---------------------------------------------------------------------------
1801
+
1802
+ # 当前工具输 skill 的写法;认不出宿主时三种都给
1803
+ SKILL_PREFIX = {"claude": "/", "codex": "$", "pi": "/skill:", "omp": "/skill:"}
1804
+ ENTRY_WORDS = {"setup": ("设置", "setup"), "log-review": ("日志复查", "log-review")}
1805
+ SENT_ITEMS = (
1806
+ "这次运行在决定日志里写的那一段,加这次对话的聊天记录(你和我说的话,以及每条命令和它结果的开头结尾几行),发出前先打码。",
1807
+ "this run's part of the decision log and this conversation's chat (what you and I said, plus the first and last"
1808
+ " lines of each command and its result), redacted first.",
1809
+ )
1810
+ SKIP_WHY = {
1811
+ "no-settings": ("这台电脑还没设复查员", "no reviewers are set on this computer"),
1812
+ "none-qualified": ("设置里的复查员这次都不能跑", "none of the reviewers in the settings can run this time"),
1813
+ NEEDS_USER_CURRENT_AI: ("认不出写改动的是哪家 AI,没法避开同家的复查员",
1814
+ "which family the AI writing the changes is from cannot be told, so a reviewer of the"
1815
+ " same family cannot be avoided"),
1816
+ }
1817
+ MODEL_LINE_SHAPES = {
1818
+ "claude": ("系统提示里以 `You are powered by the model` 开头的那一整行",
1819
+ "the whole line of the system prompt that starts `You are powered by the model`"),
1820
+ "omp": ("系统提示里 `- Model: <provider>/<id>` 那一整行", "the whole `- Model: <provider>/<id>` line of the system prompt"),
1821
+ }
1822
+ LEAVE_FENCE = {
1823
+ "claude": ("在 Claude Code 里用 `/sandbox` 关掉命令围栏", "turn Claude Code's command sandbox off in `/sandbox`"),
1824
+ "codex": ("不带围栏重新启动 Codex(`--dangerously-bypass-approvals-and-sandbox`,即全权限)",
1825
+ "start Codex again without its sandbox (`--dangerously-bypass-approvals-and-sandbox`, full access)"),
1826
+ }
1827
+
1828
+
1829
+ def skill_input(cmd: str, host: str | None) -> str | None:
1830
+ """当前宿主里输这条 skill 命令的写法(不带反引号);认不出宿主时给 None。"""
1831
+ return f"{SKILL_PREFIX[host]}{cmd}" if host in SKILL_PREFIX else None
1832
+
1833
+
1834
+ def _typed(cmd: str, host: str | None, en: bool) -> str:
1835
+ typed = skill_input(cmd, host)
1836
+ if typed is not None:
1837
+ return f"`{typed}`"
1838
+ return (f"`/{cmd}` (Claude Code), `${cmd}` (Codex) or `/skill:{cmd}` (pi, omp)" if en
1839
+ else f"`/{cmd}`(Claude Code)、`${cmd}`(Codex)或 `/skill:{cmd}`(pi、omp)")
1840
+
1841
+
1842
+ def _entry(word: str, host: str | None, en: bool) -> str:
1843
+ return _typed(f"atw-cross-model-review {ENTRY_WORDS[word][1 if en else 0]}", host, en)
1844
+
1845
+
1846
+ def _branch_sentence(skill: str, branch: str, opened: bool, en: bool) -> str:
1847
+ if skill == "atw-implement-spec":
1848
+ if opened:
1849
+ return (f"This run opened the integration branch `{branch}`; every ticket lands on it." if en
1850
+ else f"这次运行新开了集成分支 `{branch}`,各工单都合入这条分支。")
1851
+ return (f"The integration branch is the existing `{branch}`; every ticket lands on it." if en
1852
+ else f"集成分支沿用已有的 `{branch}`,各工单都合入这条分支。")
1853
+ if opened:
1854
+ return (f"This run opened a new branch, `{branch}`, and commits everything on it." if en
1855
+ else f"这次运行新开了分支 `{branch}`,改动都提交在这条分支上。")
1856
+ return (f"This run opened no new branch; it works on the current branch, `{branch}`." if en
1857
+ else f"这次运行没有新开分支,就在当前分支 `{branch}` 上做。")
1858
+
1859
+
1860
+ def _approval_sentence(host: str | None, en: bool) -> str:
1861
+ """宿主会不会逐条要授权:只说查得到的(来源写明),启动时的开关查不到就说查不到。"""
1862
+ override = ("a switch given at start-up can override it, which cannot be found out here" if en
1863
+ else "启动时给的开关能盖过它,这里查不到")
1864
+ if host == "claude":
1865
+ found = _claude_permission_mode()
1866
+ if found is None:
1867
+ return ("Whether Claude Code asks you to approve each command: no `permissions.defaultMode` is set in its"
1868
+ " settings; a switch given at start-up can override the settings, which cannot be found out here."
1869
+ if en else "Claude Code 会不会每条命令都要你授权:设置里没写 `permissions.defaultMode`;"
1870
+ "启动时给的开关能盖过设置,这里查不到。")
1871
+ mode, f = found
1872
+ means = (", so it does not ask" if en else ",即不逐条要授权") if mode == "bypassPermissions" else ""
1873
+ return (f"Whether Claude Code asks you to approve each command: `permissions.defaultMode` in `{f}` is"
1874
+ f" `{mode}`{means}; {override}." if en else
1875
+ f"Claude Code 会不会每条命令都要你授权:设置 `{f}` 里 `permissions.defaultMode` 是 `{mode}`{means};{override}。")
1876
+ if host == "codex":
1877
+ def means(policy: str) -> str:
1878
+ return (", so it never stops to ask" if en else ",即不停下来要授权") if policy == "never" else ""
1879
+ effective = _codex_session_value(os.environ.get("CODEX_THREAD_ID", ""), "approval_policy")
1880
+ if effective:
1881
+ v, f = effective
1882
+ return (f"Whether Codex asks you to approve each command: `approval_policy` is `{v}` in this session's"
1883
+ f" record `{f}`{means(v)}." if en else
1884
+ f"Codex 会不会每条命令都要你授权:这次会话的记录 `{f}` 里 `approval_policy` 是 `{v}`{means(v)}。")
1885
+ configured = _codex_config_approval()
1886
+ if configured:
1887
+ v, f = configured
1888
+ return (f"Whether Codex asks you to approve each command: this session's record cannot be read;"
1889
+ f" `approval_policy` in the config `{f}` is `{v}`{means(v)}; {override}." if en else
1890
+ f"Codex 会不会每条命令都要你授权:读不到这次会话的记录;配置 `{f}` 里 `approval_policy` 是"
1891
+ f" `{v}`{means(v)};{override}。")
1892
+ return ("Whether Codex asks you to approve each command cannot be found out here: this session's record cannot"
1893
+ " be read, and the config sets no `approval_policy`." if en else
1894
+ "Codex 会不会每条命令都要你授权,这里查不到:读不到这次会话的记录,配置里也没写 `approval_policy`。")
1895
+ if host in TOOL_NAMES:
1896
+ return (f"Whether {TOOL_NAMES[host]} asks you to approve each command cannot be found out here." if en
1897
+ else f"{TOOL_NAMES[host]} 会不会每条命令都要你授权,这里查不到。")
1898
+ return ("Which tool this is cannot be told, so whether it asks you to approve each command cannot be found out"
1899
+ " here." if en else "认不出这是哪个工具,它会不会每条命令都要你授权,这里查不到。")
1900
+
1901
+
1902
+ def _skipped_review(pick: dict, restart: str, fences: list[str], en: bool) -> list[str]:
1903
+ """日志复查会跳过:为什么、现在怎么补(只给当前工具的写法)、事后怎么补。"""
1904
+ host, why = pick["host"], pick["unavailable"]
1905
+ reason, detail = why["reason"], why["detail"]
1906
+ if reason == "settings-broken":
1907
+ cause = (f"the reviewer settings cannot be read ({detail})" if en else f"复查员设置读不了({detail})")
1908
+ elif reason == "sandbox-needs-approval":
1909
+ by = " and ".join(TOOL_NAMES[b] for b in fences) if en else "、".join(TOOL_NAMES[b] for b in fences)
1910
+ cause = (f"the reviewers need the network to reach their models, so the commands would have to leave {by}'s"
1911
+ " sandbox, which needs your approval, and nobody will be there to give it" if en else
1912
+ f"复查员要连网找模型,命令得跑到 {by} 的围栏外,这要你点头,到时没人能点")
1913
+ else:
1914
+ cause = SKIP_WHY[reason][1 if en else 0]
1915
+ sent = [f"The log review at the end will be skipped this time: {cause}." if en
1916
+ else f"收尾时的日志复查这次会跳过:{cause}。"]
1917
+ skipped = [s for s in pick.get("skipped") or [] if s.get("from") == "settings"]
1918
+ if skipped:
1919
+ sent.append(_skipped_line(skipped, pick.get("avoid") or [], en) + ("." if en else "。"))
1920
+ setup = _entry("setup", host, en)
1921
+ if reason == NEEDS_USER_CURRENT_AI:
1922
+ fix = None
1923
+ elif reason == "no-settings":
1924
+ fix = f"type {setup} to set reviewers" if en else f"输 {setup} 设好复查员"
1925
+ elif reason == "settings-broken":
1926
+ fix = (f"fix the file named above, or type {setup} to set them again" if en
1927
+ else f"改好上面说的那个文件,或输 {setup} 重新设")
1928
+ elif reason == "sandbox-needs-approval":
1929
+ fix = (", and " if en else ",并").join(LEAVE_FENCE[b][1 if en else 0] for b in fences)
1930
+ elif skipped and all(s["reason"] == "same-family" for s in skipped):
1931
+ fix = f"type {setup} to add a reviewer of another family" if en else f"输 {setup} 加一个别家的复查员"
1932
+ else:
1933
+ fix = (f"fix them by the reasons above, or type {setup} to change them" if en
1934
+ else f"按上面的原因把它们弄好,或输 {setup} 换人")
1935
+ if reason == NEEDS_USER_CURRENT_AI and host in MODEL_LINE_SHAPES and pick["current"] is None:
1936
+ # 这两个宿主认不出 AI 只会是没交写模型的那一行(交错了已退出 2):照抄那一行重跑就补上了
1937
+ shape = MODEL_LINE_SHAPES[host][1 if en else 0]
1938
+ sent.append(f"To fix it now: run this `afk-notice` command again with `--model-line` set to {shape}." if en
1939
+ else f"要现在补:带上 `--model-line`,照抄{shape},重跑这条 `afk-notice` 命令。")
1940
+ elif fix is None:
1941
+ sent.append("There is no fix before the run." if en else "开跑前没有办法补。")
1942
+ else:
1943
+ sent.append(f"To fix it now: interrupt this run, {fix}, then type {restart} to start again." if en
1944
+ else f"要现在补:打断这次运行,{fix},再输 {restart} 重新开跑。")
1945
+ review = _entry("log-review", host, en)
1946
+ sent.append(f"To make it up afterwards, type {review}." if en else f"事后补做:输 {review}。")
1947
+ sent.append(f"So nothing is sent at the end; making it up sends {SENT_ITEMS[1]}" if en
1948
+ else f"所以收尾时什么都不发出去;补做时发的是{SENT_ITEMS[0]}")
1949
+ return sent
1950
+
1951
+
1952
+ def build_afk_notice(pick: dict, skill: str, branch: str, opened: bool, max_n: int | None, task: str) -> dict:
1953
+ """`--afk` 开跑提醒(中英各一段):prd F 节各项,复查员与跳过原因取自同一次 pick --settings-only。"""
1954
+ host = pick["host"]
1955
+ restart_cmd = f"{skill} --afk" + (f" --max {max_n}" if max_n is not None else "")
1956
+ fences = [b["by"] for b in host_sandbox()["sandboxes"]] if pick["unavailable"] else []
1957
+ waiting = posixpath.join(task.rstrip("/"), "waiting-on-you.md")
1958
+ notice = {}
1959
+ for lang in ("zh", "en"):
1960
+ en = lang == "en"
1961
+ sent = [_branch_sentence(skill, branch, opened, en)]
1962
+ if pick["unavailable"]:
1963
+ sent += _skipped_review(pick, _typed(restart_cmd, host, en), fences, en)
1964
+ else:
1965
+ rs = pick["reviewers"]
1966
+ fams = [families_of(r["model"], r["tool"]) for r in rs]
1967
+ listed = [_reviewer_part(r, f, en) for r, f in zip(rs, fams)]
1968
+ if len(rs) == 1:
1969
+ sent.append(f"The log review at the end goes to {listed[0]}." if en else f"收尾时的日志复查交给 {listed[0]}。")
1970
+ else:
1971
+ sent.append(f"The log review at the end goes to {len(rs)} reviewers, at the same time on the same"
1972
+ f" material: {'; '.join(listed)}." if en else
1973
+ f"收尾时的日志复查交给 {len(rs)} 个复查员,同时跑、材料相同:{';'.join(listed)}。")
1974
+ sent += [_unrecognised_sentence(r, en) for r, f in zip(rs, fams) if not f]
1975
+ skipped = [s for s in pick["skipped"] if s.get("from") == "settings"]
1976
+ if skipped:
1977
+ sent.append(_skipped_line(skipped, pick["avoid"], en) + ("." if en else "。"))
1978
+ sent.append(f"What they will be sent is {SENT_ITEMS[1]}" if en else f"到时发给复查员的是{SENT_ITEMS[0]}")
1979
+ sent.append(_approval_sentence(host, en))
1980
+ sent.append("If an approval is asked for mid-run, the run halts there and waits for you; I never change the"
1981
+ " host's settings for you." if en else "中途如果要你授权,运行会停在那里等你;我不替你改宿主的设置。")
1982
+ sent.append("Five red lines stay uncrossed: no push, no merge into the base branch (nor a commit on it), no"
1983
+ " publishing, no deleting what git cannot bring back, no lowering the bar." if en else
1984
+ "五条红线不碰:不推送,不合入主线(也不直接在主线上提交),不发布,不删 git 找不回的东西,不降低标准。")
1985
+ sent.append(f"Whatever needs you goes into the waiting-on-you list, `{waiting}`, and the run goes around it and"
1986
+ " carries on." if en else f"要你处理的事记进「等你处理」清单 `{waiting}`,记下后绕开接着做。")
1987
+ sent.append("This only tells you; it does not wait for an answer, and you may interrupt the run at any time."
1988
+ if en else "这段只是交代,不等你回答;你随时可以打断这次运行。")
1989
+ notice[lang] = (" " if en else "").join(sent)
1990
+ return notice
1991
+
1992
+
1993
+ # ---------------------------------------------------------------------------
1994
+ # 子命令
1995
+ # ---------------------------------------------------------------------------
1996
+
1997
+
1998
+ def _write_frozen(
1999
+ root: Path,
2000
+ part: dict,
2001
+ scope: dict,
2002
+ group: tuple[int, int, list[str]] | None,
2003
+ questions: str,
2004
+ task: Path | None,
2005
+ intent: str | None,
2006
+ reviewers: list[dict],
2007
+ picked: str | None,
2008
+ made: list[Path],
2009
+ ) -> dict:
2010
+ """把一份补丁连同材料与题目写进自己的编号目录;返回这一份的摘要(也是 run 读的 manifest)。"""
2011
+ review_id = time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
2012
+ out_dir = root / RUNTIME_SUBDIR / review_id
2013
+ out_dir.mkdir(parents=True)
2014
+ made.append(out_dir)
2015
+ patch_rel = (RUNTIME_SUBDIR / review_id / "patch.diff").as_posix()
2016
+ (root / patch_rel).write_text(part["patchText"], encoding="utf-8")
2017
+
2018
+ tickets_rel = None
2019
+ if task is not None and (task / "issues").is_dir():
2020
+ tickets_rel = (RUNTIME_SUBDIR / review_id / "tickets.md").as_posix()
2021
+ (root / tickets_rel).write_text(ticket_bodies(task / "issues"), encoding="utf-8")
2022
+
2023
+ picked_rel = None
2024
+ if picked is not None:
2025
+ picked_rel = (RUNTIME_SUBDIR / review_id / "picked.md").as_posix()
2026
+ (root / picked_rel).write_text(picked, encoding="utf-8")
2027
+
2028
+ prompt_rel = (RUNTIME_SUBDIR / review_id / "prompt.md").as_posix()
2029
+ prompt = build_prompt(
2030
+ root, questions, scope, part["pathOnly"], patch_rel, group, task, tickets_rel, intent, picked_rel
2031
+ )
2032
+ (root / prompt_rel).write_text(prompt, encoding="utf-8")
2033
+
2034
+ summary = {
2035
+ "id": review_id,
2036
+ "dir": (RUNTIME_SUBDIR / review_id).as_posix(),
2037
+ "patch": patch_rel,
2038
+ "prompt": prompt_rel,
2039
+ **{k: v for k, v in part.items() if k not in ("patchText", "linesByFile")},
2040
+ }
2041
+ manifest = {**scope, **summary, "linesByFile": part["linesByFile"], "reviewers": reviewers}
2042
+ (out_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
2043
+ return summary
2044
+
2045
+
2046
+ def _split(frozen: dict, groups: list[list[str]]) -> list[dict]:
2047
+ """把同一次冻结按组分开;有文件哪组都不在、或某组一个文件都没有时报错。"""
2048
+ pieces, path_only = frozen["pieces"], frozen["pathOnly"]
2049
+ names = sorted(pieces) + [x["path"] for x in path_only]
2050
+ where = dict(zip(names, assign_groups(names, groups)))
2051
+ stray = [n for n, g in where.items() if g is None]
2052
+ if stray:
2053
+ raise ReviewError("这些文件不在任何一组里:" + "、".join(sorted(stray)))
2054
+ parts = []
2055
+ for i, members in enumerate(groups):
2056
+ mine = {p: t for p, t in pieces.items() if where[p] == i}
2057
+ mine_only = [x for x in path_only if where[x["path"]] == i]
2058
+ if not mine and not mine_only:
2059
+ raise ReviewError(f"第 {i + 1} 组({', '.join(members)})没有改动")
2060
+ parts.append(summarize(mine, frozen["lines"], mine_only))
2061
+ return parts
2062
+
2063
+
2064
+ def cmd_prepare(args: argparse.Namespace) -> dict:
2065
+ reviewers = [parse_reviewer(s) for s in args.reviewer]
2066
+ questions_path = Path(args.questions)
2067
+ if not questions_path.is_file():
2068
+ raise ReviewError(f"找不到题目文件:{questions_path}")
2069
+ if args.whole and args.group:
2070
+ raise ReviewError("--whole 与 --group 只能选一个:整段看还是分开看")
2071
+ if args.recheck and args.since:
2072
+ raise ReviewError("--recheck 与 --since 只能给一个:复核从上次的快照算起")
2073
+ if bool(args.recheck) != bool(args.picked_file):
2074
+ raise ReviewError("--recheck 与 --picked-file 要一起给:复核的材料要有上次报告里用户选去改的条目")
2075
+ if (args.expect_base is None) != (args.expect_patch is None):
2076
+ raise ReviewError("--expect-base 与 --expect-patch 要一起给")
2077
+ if args.expect_patch is not None and args.group:
2078
+ raise ReviewError("--expect-patch 不与 --group 同用:重跑一组时用 --path 给这组的文件夹或文件")
2079
+ groups = [[m.strip() for m in g.split(",") if m.strip()] for g in args.group or []]
2080
+ if any(not g for g in groups):
2081
+ raise ReviewError("--group 不能是空的")
2082
+ try:
2083
+ pick = json.loads(args.pick) if args.pick is not None else None
2084
+ except ValueError as e:
2085
+ raise ReviewError(f"--pick 要原样给 pick 输出的那个 JSON 对象:{e}") from e
2086
+ if pick is not None:
2087
+ _check_pick(pick)
2088
+ root = repo_root()
2089
+ task = Path(args.task).resolve() if args.task else None
2090
+ if task is not None and not task.is_dir():
2091
+ raise ReviewError(f"找不到任务目录:{args.task}")
2092
+ intent = Path(args.intent_file).read_text(encoding="utf-8") if args.intent_file else None
2093
+ picked = Path(args.picked_file).read_text(encoding="utf-8") if args.picked_file else None
2094
+ if intent is None and (task is None or not (task / "prd.md").is_file()):
2095
+ raise ReviewError("没有任务的 prd.md 时,要用 --intent-file 交一段意图")
2096
+
2097
+ paths = args.path or []
2098
+ if args.recheck:
2099
+ if not _ref_exists(root, args.recheck):
2100
+ raise ReviewError(f"认不出上次的快照 {args.recheck}(复查报告里写的快照编号)")
2101
+ last = _git(root, ["rev-parse", f"{args.recheck}^{{commit}}"]).strip()
2102
+ start = {"base": args.recheck, "baseFrom": "snapshot", "onBase": False, "baseCommit": last}
2103
+ # 上次只看了某些文件夹或文件时,复核也只看那里
2104
+ paths = paths or snapshot_paths(root, last)
2105
+ else:
2106
+ start = resolve_start(root, args.since, task)
2107
+ # 先存快照再从它冻结:补丁就是快照里看到的样子
2108
+ snap = snapshot_commit(root, paths)
2109
+ frozen = freeze(root, start["baseCommit"], snap, paths)
2110
+ whole = summarize(frozen["pieces"], frozen["lines"], frozen["pathOnly"])
2111
+ scope = {**start, "paths": paths, "lineLimit": LINE_LIMIT}
2112
+
2113
+ personal = codex_personal_instructions()
2114
+ for r in reviewers:
2115
+ r["personalInstructions"] = personal if r["tool"] == "codex" else None
2116
+
2117
+ out = {
2118
+ **scope,
2119
+ **{k: v for k, v in whole.items() if k not in ("patchText", "linesByFile")},
2120
+ "skippedSecretLike": frozen["skippedSecretLike"],
2121
+ "reviewers": reviewers,
2122
+ "stop": None,
2123
+ # 重试或换人:与第一次的起点提交、补丁是否相同;不是重跑时为 None
2124
+ "sameAsBefore": None if args.expect_patch is None else {
2125
+ "baseCommit": start["baseCommit"] == args.expect_base,
2126
+ "patch": whole["patchHash"] == args.expect_patch,
2127
+ },
2128
+ }
2129
+ if not whole["files"] and not whole["pathOnly"]:
2130
+ # 复核时上次之后没有改动:不重跑(prd O 节),与第一次就没有改动分开
2131
+ return {**out, "stop": "no-changes-since-snapshot" if args.recheck else "no-changes"}
2132
+ if not groups and whole["totalLines"] > LINE_LIMIT and not args.whole:
2133
+ return {**out, "stop": "over-limit", "suggestedGroups": suggest_groups(whole["linesByFolder"], whole["pathOnly"])}
2134
+
2135
+ questions = questions_path.read_text(encoding="utf-8")
2136
+ # 先分好再写:有一组不对就什么都不冻结
2137
+ parts = _split(frozen, groups) if groups else []
2138
+ # 「上次查到哪」:复核时以它为起点(prd O 节);分组时各组共用一个
2139
+ keep_snapshot(root, snap)
2140
+ out["snapshot"] = scope["snapshot"] = snap
2141
+ made: list[Path] = []
2142
+ try:
2143
+ if not groups:
2144
+ out = {**out, **_write_frozen(root, whole, scope, None, questions, task, intent, reviewers, picked, made)}
2145
+ else:
2146
+ out["groups"] = [
2147
+ {"paths": members, **_write_frozen(
2148
+ root, part, scope, (i + 1, len(parts), members), questions, task, intent, reviewers, picked, made
2149
+ )}
2150
+ for i, (members, part) in enumerate(zip(groups, parts))
2151
+ ]
2152
+ return {**out, "notice": build_notice(out, pick)}
2153
+ except (OSError, ReviewError) as e:
2154
+ # 退出 2 说什么都没冻结:快照引用与写了一半的编号目录一并撤掉
2155
+ _git(root, ["update-ref", "-d", SNAPSHOT_REF_PREFIX + snap], ok_codes=(0, 1))
2156
+ for d in made:
2157
+ shutil.rmtree(d, ignore_errors=True)
2158
+ raise ReviewError(f"写不了这次复查的材料:{e}") from e
2159
+
2160
+
2161
+ # ---------------------------------------------------------------------------
2162
+ # 日志复查(decision-log-afk 工单 09,prd K 节):材料是决定日志与聊天记录,不冻结补丁
2163
+ # ---------------------------------------------------------------------------
2164
+
2165
+ LOG_NAME = "decision-log.md"
2166
+ # 聊天材料里每条命令结果留的开头结尾行数:平时 20,材料太长重试时 5(prd O 节)
2167
+ RESULT_LINES = 20
2168
+ SHORT_RESULT_LINES = 5
2169
+ # atw mem extract --context 认得出的编号前缀(与 core mem 的 CONTEXT_PREFIX_PLATFORMS 一致)
2170
+ MEM_CONTEXT_PREFIXES = ("claude", "codex", "grok", "omp", "opencode", "pi", "session")
2171
+ # ATW 自己算出的对话编号都以平台名(或认不出平台时的 session)加 `_` 开头;不带这种前缀的
2172
+ # 多半是 ATW_CONTEXT_ID 手动覆盖的原样值(resolve_context_key 只把它净化,不加前缀)
2173
+ ATW_CONTEXT_PREFIXES = frozenset(KNOWN_PLATFORMS) | frozenset(MEM_CONTEXT_PREFIXES)
2174
+ _MEM_NOT_FOUND = "no session matches ATW context id"
2175
+ # prepare-log 只写 --session 不带值时的占位:换成这次对话的编号
2176
+ CURRENT_SESSION = object()
2177
+
2178
+
2179
+ def _log_runs(rows: list[Row], scope: str, session: str | None) -> list[dict]:
2180
+ """这次要看的运行:默认最近一次(最后一条 start 行那个对话写的行),--whole 时每个对话一段,
2181
+ --session 时只取那个对话的(调用方先确认它在日志里有 start 行)。
2182
+
2183
+ 一段归哪个对话照 decision_log 的 owners:被别的对话插写后重写的 start 也算同一个对话。
2184
+ """
2185
+ owner = owners(rows)
2186
+ starts = [i for i, r in enumerate(rows) if r.phase == START_PHASE]
2187
+ if scope == "session":
2188
+ sessions = [session]
2189
+ elif scope == "whole":
2190
+ sessions = list(dict.fromkeys(rows[i].evidence for i in starts))
2191
+ else:
2192
+ sessions = [rows[starts[-1]].evidence]
2193
+ runs = []
2194
+ for s in sessions:
2195
+ mine = [r for r, o in zip(rows, owner) if o == s]
2196
+ runs.append({"session": s, "start": mine[0].raw_time, "end": mine[-1].raw_time, "rows": mine})
2197
+ return runs
2198
+
2199
+
2200
+ def _context_from_override(session: str) -> bool:
2201
+ """这个编号是不是这条命令的 `ATW_CONTEXT_ID` 手动覆盖算出来的(与 resolve_context_key 同算法)。"""
2202
+ override = (os.environ.get("ATW_CONTEXT_ID") or "").strip()
2203
+ if not override:
2204
+ return False
2205
+ safe = re.sub(r"[^A-Za-z0-9._-]+", "_", override).strip("._-")[:160]
2206
+ return session == (safe or hashlib.sha256(override.encode("utf-8")).hexdigest()[:24])
2207
+
2208
+
2209
+ def _chat_problem(session: str, start: str, error: str | None, code: int | None) -> dict:
2210
+ """找不到这次对话时照实给原因,不猜、不拿别的对话充数(工单 03 评论:手动覆盖的原样值对不上会话)。"""
2211
+ if session in {spec.no_session for spec in LOG_LANGS.values()}:
2212
+ reason = "no-session-id"
2213
+ elif _context_from_override(session) or "_" not in session or session.partition("_")[0] not in ATW_CONTEXT_PREFIXES:
2214
+ reason = "manual-override"
2215
+ elif session.partition("_")[0] not in MEM_CONTEXT_PREFIXES:
2216
+ # ATW 认得的平台,但 atw mem 读不了它的会话(prd P 节)
2217
+ reason = "unreadable-id"
2218
+ elif code == 2 and error and _MEM_NOT_FOUND in error:
2219
+ reason = "not-found"
2220
+ elif code == 2 and error and "matches" in error and "sessions" in error:
2221
+ reason = "ambiguous"
2222
+ else:
2223
+ reason = "mem-failed"
2224
+ return {"session": session, "start": start, "reason": reason, "error": error}
2225
+
2226
+
2227
+ # atw mem 的另两处截短(core mem/internal/tool-text.ts 的 clipValue、adapters/pi.ts 的 firstLine):
2228
+ # 参数摘要里每个值只留首行前 200 个字,换路标记引的那行话只留前 80 个字(都按 UTF-16 单位数)
2229
+ ARG_VALUE_MAX = 200
2230
+ SWITCH_QUOTE_MAX = 80
2231
+
2232
+
2233
+ def _extract_chat(atw: str, root: Path, session: str, start: str) -> tuple[dict | None, int, str]:
2234
+ """`atw mem extract --context <编号> --tools --after <start 行时间> --uncut --json`:返回(JSON,退出码,报错)。"""
2235
+ proc = subprocess.run(
2236
+ [atw, "mem", "extract", "--context", session, "--tools", "--after", start, "--uncut", "--json"],
2237
+ cwd=root,
2238
+ capture_output=True,
2239
+ text=True,
2240
+ encoding="utf-8",
2241
+ errors="replace",
2242
+ )
2243
+ err = proc.stderr.strip()[-2000:]
2244
+ if proc.returncode != 0:
2245
+ return None, proc.returncode, err
2246
+ try:
2247
+ data = json.loads(proc.stdout)
2248
+ except ValueError as e:
2249
+ return None, proc.returncode, f"atw mem 的输出不是 JSON:{e}"
2250
+ if not isinstance(data, dict) or not isinstance(data.get("items"), list):
2251
+ return None, proc.returncode, "atw mem 的 JSON 输出里没有 items"
2252
+ # 换路引话要先对整段原话打码;旧版 atw 不给 quoteWhole,只打码首行会漏私钥,当读不了
2253
+ if any(isinstance(i, dict) and i.get("kind") == "branch_switch" and i.get("quote") and "quoteWhole" not in i
2254
+ for i in data["items"]):
2255
+ return None, proc.returncode, "atw 版本太旧:换路标记没有 quoteWhole(被引条目的整段原话),无法先整段打码"
2256
+ return data, proc.returncode, err
2257
+
2258
+
2259
+ def _js_units(text: str) -> bytes:
2260
+ return text.encode("utf-16-le", "surrogatepass")
2261
+
2262
+
2263
+ def _js_slice(text: str, n: int) -> str:
2264
+ """JS 的 `text.slice(0, n)`:按 UTF-16 单位取;切开的代理对与 Node 输出时一样成 U+FFFD。"""
2265
+ return _js_units(text)[:2 * n].decode("utf-16-le", "replace")
2266
+
2267
+
2268
+ def _clip_value(value: str) -> str:
2269
+ """与 atw mem 的 clipValue 一致:多行或首行超过 200 个字时只留首行前 200 个字,写明少了几个字。"""
2270
+ first = value.split("\n", 1)[0]
2271
+ if len(_js_units(first)) <= 2 * ARG_VALUE_MAX and first == value:
2272
+ return value
2273
+ kept = _js_slice(first, ARG_VALUE_MAX)
2274
+ return f"{kept} …(+{(len(_js_units(value)) - len(_js_units(kept))) // 2} chars)"
2275
+
2276
+
2277
+ def _clip_quote(whole: str) -> str:
2278
+ """与 atw mem 的 firstLine 一致:取(已整段打码的)原话首行,超过 80 个字时留前 80 个字加「…」。"""
2279
+ line = whole.split("\n", 1)[0]
2280
+ return f"{_js_slice(line, SWITCH_QUOTE_MAX)}…" if len(_js_units(line)) > 2 * SWITCH_QUOTE_MAX else line
2281
+
2282
+
2283
+ def _head_tail(text: str, n: int) -> str:
2284
+ """与 atw mem(core mem/timeline.ts 的 headTail)一致:留开头、结尾各 n 行,中间写明省略了多少行。"""
2285
+ lines = text.rstrip("\n").split("\n")
2286
+ if len(lines) <= 2 * n:
2287
+ return text
2288
+ omitted = len(lines) - 2 * n
2289
+ return "\n".join([*lines[:n], f"[... {omitted} lines omitted ...]", *lines[len(lines) - n:]])
2290
+
2291
+
2292
+ def _render_timeline(data: dict, result_lines: int, types: list[str]) -> str:
2293
+ """照 `atw mem extract --tools` 的文字输出(cli commands/mem.ts 的 printTimeline)渲染:
2294
+
2295
+ 每条先整段打码(记号,见 `_mark_secrets`),再照 atw mem 截短——参数值、换路引的话、命令输入与结果的
2296
+ 首尾行——省略的行数、字数按打码后的文字算。
2297
+ """
2298
+ def mark(value: object) -> str:
2299
+ return _mark_secrets(str(value), types)
2300
+
2301
+ s = data.get("session") or {}
2302
+ out = [f"# session: [{s.get('platform')}] {s.get('id')}"]
2303
+ if s.get("title"):
2304
+ out.append(f"# title: {s['title']}")
2305
+ if s.get("cwd"):
2306
+ out.append(f"# cwd: {str(s['cwd']).replace(str(Path.home()), '~', 1)}")
2307
+ if s.get("created"):
2308
+ out.append(f"# date: {str(s['created'])[:16].replace('T', ' ', 1)}")
2309
+ items = data["items"]
2310
+ out.append(f"# reading: tools result-lines: {result_lines}"
2311
+ + (f" after: {data['after']}" if data.get("after") else "")
2312
+ + f" items: {len(items)}")
2313
+ out = [mark(line) for line in out] + [""]
2314
+ for item in items:
2315
+ kind = item.get("kind")
2316
+ at = f" ({item['timestamp']})" if item.get("timestamp") else ""
2317
+ call_id = f" [{item['callId']}]" if kind in ("tool_call", "tool_result") and item.get("callId") else ""
2318
+ if kind == "text":
2319
+ head, body = f"## {'Human' if item.get('role') == 'user' else 'Assistant'}{at}", mark(item.get("text", ""))
2320
+ elif kind == "marker":
2321
+ head, body = f"## Marker{at}", mark(item.get("text", ""))
2322
+ elif kind == "branch_switch":
2323
+ text, quote = item.get("text", ""), item.get("quote")
2324
+ at_quote = text.rfind(quote) if quote else -1
2325
+ if at_quote < 0:
2326
+ body = mark(text)
2327
+ else:
2328
+ # 引的那行换成整段原话打码后的首行:私钥 BEGIN 与正文在首行、END 在后面时也遮得住
2329
+ body = (mark(text[:at_quote]) + _clip_quote(mark(item["quoteWhole"]))
2330
+ + mark(text[at_quote + len(quote):]))
2331
+ head = f"## Branch switch{at}"
2332
+ if item.get("summary"):
2333
+ body += f"\n\nBranch summary:\n{mark(item['summary'])}"
2334
+ elif kind == "tool_call":
2335
+ head = f"## Tool call: {item.get('name')}{call_id}{at}"
2336
+ if isinstance(item.get("args"), list):
2337
+ # 参数摘要:每行 `名字: 值` 整行打码(名字能帮着认出密钥),再截值
2338
+ lines = []
2339
+ for a in item["args"]:
2340
+ prefix = f"{a['name']}: "
2341
+ marked = mark(prefix + a["value"])
2342
+ lines.append(prefix + _clip_value(marked[len(prefix):]) if marked.startswith(prefix)
2343
+ else _clip_value(marked))
2344
+ body = _head_tail("\n".join(lines), result_lines)
2345
+ else:
2346
+ body = _head_tail(mark(item.get("input", "")), result_lines)
2347
+ elif kind == "tool_result":
2348
+ head = (f"## Tool result: {item.get('name') or '(unknown call)'}{call_id}"
2349
+ f"{' (error)' if item.get('isError') else ''}{at}")
2350
+ body = _head_tail(mark(item.get("output", "")), result_lines)
2351
+ elif kind == "hook_message":
2352
+ head, body = f"## Hook message: {item.get('hook')}{at}", mark(item.get("text", ""))
2353
+ else:
2354
+ # 认不出的种类:atw mem 文字输出里也不显示它的内容,只留分隔线
2355
+ out += ["", "---", ""]
2356
+ continue
2357
+ out += [mark(head), "", body, "", "---", ""]
2358
+ return "\n".join(out) + "\n"
2359
+
2360
+
2361
+ def _chat_texts(atw: str, root: Path, runs: list[dict], problems: list[dict]) -> tuple[str, str, list[str]]:
2362
+ """每次运行的聊天记录只读一次,出完整版(各 20 行)与短版(各 5 行);打码记号的类型在第三项。"""
2363
+ types: list[str] = []
2364
+ full, short = [], []
2365
+ for i, run in enumerate(runs, 1):
2366
+ data, code, err = None, None, None
2367
+ if run["session"] not in {spec.no_session for spec in LOG_LANGS.values()}:
2368
+ data, code, err = _extract_chat(atw, root, run["session"], run["start"])
2369
+ if data is None:
2370
+ problems.append(_chat_problem(run["session"], run["start"], err, code))
2371
+ continue
2372
+ head = f"===== Run {i}: session {run['session']}, from {run['start']} =====\n\n"
2373
+ full.append(head + _render_timeline(data, RESULT_LINES, types).strip() + "\n")
2374
+ short.append(head + _render_timeline(data, SHORT_RESULT_LINES, types).strip() + "\n")
2375
+ return "\n".join(full), "\n".join(short), types
2376
+
2377
+
2378
+ def _log_text(lang: str, rows: list[Row]) -> str:
2379
+ spec = LOG_LANGS[lang]
2380
+ lines = [spec.title, "", format_row(list(spec.header)), format_row(["---"] * 6)]
2381
+ lines += [format_row([r.raw_time, r.phase, r.decision, r.why, r.evidence, r.result]) for r in rows]
2382
+ return "\n".join(lines) + "\n"
2383
+
2384
+
2385
+ def build_log_prompt(questions: str, out: dict, chat_rel: str, result_lines: int) -> str:
2386
+ runs = out["runs"]
2387
+ if out["scope"] == "whole":
2388
+ what = f"the whole log, {out['rows']} rows written over {len(runs)} runs (each run starts at its `start` row)."
2389
+ chat = "The chat of each run, one section per run"
2390
+ else:
2391
+ r = runs[0]
2392
+ what = f"the rows one run wrote, from its start row at `{r['start']}` to `{r['end']}` ({r['rows']} rows)."
2393
+ chat = "The chat of that run"
2394
+ m = [
2395
+ "## Material",
2396
+ "",
2397
+ "Everything listed here is on disk under the working directory. Read it with your"
2398
+ " read-only tools. Every reviewer in this round gets exactly the same material.",
2399
+ "",
2400
+ f"- The decision log under review: `{out['files']['log']}` — {what}",
2401
+ f"- {chat}: `{chat_rel}` — what the user and the AI said from that run's start row on, each tool"
2402
+ f" call with its name and command, the first and last {result_lines} lines of each result"
2403
+ " (the lines in between are counted, not shown), and each message a hook showed the user (on Claude"
2404
+ " Code, the start notice and the notice before a log review come this way). The AI's thinking is not"
2405
+ " in it.",
2406
+ "- Anything that looked like a secret was replaced with a placeholder such as `[已打码:私钥]`"
2407
+ " before this material was written.",
2408
+ "- The evidence column points at commits, `file:line`, reports, tickets and list items. To see"
2409
+ " whether a row's evidence opens and says what the row claims, you may read any file in the"
2410
+ " repository, this task's directory included. You may not change anything.",
2411
+ ]
2412
+ return questions.rstrip() + "\n\n---\n\n" + "\n".join(m) + "\n"
2413
+
2414
+
2415
+ def _log_scope_clause(out: dict, en: bool) -> str:
2416
+ runs, k = out["runs"], out["redactedCount"]
2417
+ lines = out["resultLines"]
2418
+ if out["scope"] == "whole":
2419
+ s = (f"This log review reads the whole decision log ({out['rows']} rows, {len(runs)} runs) and the chat of"
2420
+ f" every run, each command's result cut to its first and last {lines} lines." if en else
2421
+ f"这次日志复查看整份决定日志({out['rows']} 行,{len(runs)} 次运行)和各次运行的聊天记录,"
2422
+ f"每条命令的结果只留开头结尾各 {lines} 行。")
2423
+ elif out["scope"] == "session":
2424
+ r = runs[0]
2425
+ s = (f"This log review reads the rows the run of conversation `{r['session']}` wrote in the decision log"
2426
+ f" ({r['start']} to {r['end']}, {r['rows']} rows) and that run's chat (from {r['start']}),"
2427
+ f" each command's result cut to its first and last {lines} lines." if en else
2428
+ f"这次日志复查看决定日志里对话 `{r['session']}` 那次运行写的那一段({r['start']} 至 {r['end']},{r['rows']} 行)"
2429
+ f"和那次运行的聊天记录(从 {r['start']} 起),每条命令的结果只留开头结尾各 {lines} 行。")
2430
+ else:
2431
+ r = runs[0]
2432
+ s = (f"This log review reads the rows the most recent run wrote in the decision log ({r['start']} to"
2433
+ f" {r['end']}, {r['rows']} rows) and that run's chat (session `{r['session']}`, from {r['start']}),"
2434
+ f" each command's result cut to its first and last {lines} lines." if en else
2435
+ f"这次日志复查看决定日志里最近一次运行写的那一段({r['start']} 至 {r['end']},{r['rows']} 行)"
2436
+ f"和那次运行的聊天记录(对话 `{r['session']}`,从 {r['start']} 起),每条命令的结果只留开头结尾各 {lines} 行。")
2437
+ s += ((f" Secrets were redacted before sending: {k} place{'' if k == 1 else 's'}." if k else
2438
+ " Checked for secrets before sending; none found.") if en else
2439
+ (f"发出前先打码,打了 {k} 处。" if k else "发出前查过要打码的,没有。"))
2440
+ s += (" A reviewer that fails is retried automatically by the cause of the failure, at most twice." if en else
2441
+ "没跑成的按失败原因自动重试,每个复查员最多两次。")
2442
+ return s
2443
+
2444
+
2445
+ READS_LOG = (
2446
+ "复查员可以只读地翻看仓库里的文件来核对证据,每个复查员最多 30 分钟(所有重试合计)。",
2447
+ "Reviewers may read files in the repository, read-only, to check the evidence, and each gets at most 30 minutes,"
2448
+ " all its retries included.",
2449
+ )
2450
+
2451
+
2452
+ def cmd_prepare_log(args: argparse.Namespace) -> dict:
2453
+ reviewers = [parse_reviewer(s) for s in args.reviewer]
2454
+ questions_path = Path(args.questions)
2455
+ if not questions_path.is_file():
2456
+ raise ReviewError(f"找不到题目文件:{questions_path}")
2457
+ try:
2458
+ pick = json.loads(args.pick) if args.pick is not None else None
2459
+ except ValueError as e:
2460
+ raise ReviewError(f"--pick 要原样给 pick 输出的那个 JSON 对象:{e}") from e
2461
+ if pick is not None:
2462
+ _check_pick(pick)
2463
+ root = repo_root()
2464
+ task = Path(args.task).resolve()
2465
+ if not task.is_dir():
2466
+ raise ReviewError(f"找不到任务目录:{args.task}")
2467
+ log_path = task / LOG_NAME
2468
+ try:
2469
+ existing = read_log(log_path)
2470
+ except LogError as e:
2471
+ raise ReviewError(str(e)) from e
2472
+ named = args.session is not None
2473
+ # 不带值的 --session 是这次对话:与 decision_log.py 自查的 run 同一个对话标识
2474
+ session = (resolve_context_key() if args.session is CURRENT_SESSION else args.session) if named else None
2475
+ scope = "whole" if args.whole else "session" if named else "last-run"
2476
+ out: dict = {"kind": "log-review", "stop": None, "scope": scope, "log": _rel(root, log_path), "chatProblems": []}
2477
+ if named:
2478
+ out["session"] = session
2479
+ if existing is None or not any(r.phase == START_PHASE for r in existing[1]):
2480
+ return {**out, "stop": "no-log"}
2481
+ lang, rows = existing
2482
+ if named and (session is None or session not in owners(rows)):
2483
+ # prd D 节:指定的对话在日志里没有运行就停下,不改看别的对话
2484
+ return {**out, "stop": "no-run"}
2485
+ runs = _log_runs(rows, scope, session)
2486
+ sent_rows = rows if scope == "whole" else runs[0]["rows"]
2487
+ out["runs"] = [{k: (len(v) if k == "rows" else v) for k, v in run.items()} for run in runs]
2488
+ out["rows"] = len(sent_rows)
2489
+
2490
+ atw = shutil.which("atw")
2491
+ if atw is None:
2492
+ raise ReviewError("找不到 atw 命令(PATH 里没有):读不了聊天记录")
2493
+ problems: list[dict] = []
2494
+ chat, chat_short, types = _chat_texts(atw, root, runs, problems)
2495
+ if problems:
2496
+ # prd P 节:读不了这次的聊天记录就不做,不拿别的对话充数
2497
+ return {**out, "stop": "chat-not-found", "chatProblems": problems}
2498
+
2499
+ log_text, found_log = redact(_log_text(lang, sent_rows))
2500
+ # 打码处数与位置照完整版材料算(prd F 节)
2501
+ chat_text, found_chat = _place_marks(chat, types)
2502
+ chat_short_text, _ = _place_marks(chat_short, types)
2503
+ questions = questions_path.read_text(encoding="utf-8")
2504
+ personal = codex_personal_instructions()
2505
+ for r in reviewers:
2506
+ r["personalInstructions"] = personal if r["tool"] == "codex" else None
2507
+
2508
+ review_id = time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
2509
+ rel_dir = RUNTIME_SUBDIR / review_id
2510
+ out_dir = root / rel_dir
2511
+ files = {k: (rel_dir / name).as_posix() for k, name in
2512
+ (("log", "log.md"), ("chat", "chat.md"), ("chatShort", "chat-short.md"))}
2513
+ out.update({
2514
+ "id": review_id,
2515
+ "dir": rel_dir.as_posix(),
2516
+ "prompt": (rel_dir / "prompt.md").as_posix(),
2517
+ "files": files,
2518
+ "resultLines": RESULT_LINES,
2519
+ "shortResultLines": SHORT_RESULT_LINES,
2520
+ "redactedCount": len(found_log) + len(found_chat),
2521
+ "redactions": [{"file": "log", **x} for x in found_log] + [{"file": "chat", **x} for x in found_chat],
2522
+ "reviewers": reviewers,
2523
+ })
2524
+ prompt_short = (rel_dir / "prompt-short.md").as_posix()
2525
+ try:
2526
+ out_dir.mkdir(parents=True)
2527
+ for key, text in (("log", log_text), ("chat", chat_text), ("chatShort", chat_short_text)):
2528
+ (root / files[key]).write_text(text, encoding="utf-8")
2529
+ (root / out["prompt"]).write_text(
2530
+ build_log_prompt(questions, out, files["chat"], RESULT_LINES), encoding="utf-8")
2531
+ (root / prompt_short).write_text(
2532
+ build_log_prompt(questions, out, files["chatShort"], SHORT_RESULT_LINES), encoding="utf-8")
2533
+ manifest = {"kind": "log-review", "id": review_id, "prompt": out["prompt"], "promptShort": prompt_short,
2534
+ "reviewers": reviewers}
2535
+ (out_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
2536
+ except OSError as e:
2537
+ shutil.rmtree(out_dir, ignore_errors=True)
2538
+ raise ReviewError(f"写不了这次日志复查的材料:{e}") from e
2539
+ return {**out, "notice": build_notice(out, pick, _log_scope_clause, READS_LOG)}
2540
+
2541
+
2542
+ def cmd_afk_notice(args: argparse.Namespace) -> dict:
2543
+ """两个连做 skill `--afk` 开跑时:照收尾那样只看设置挑人,另给写好的开跑提醒。"""
2544
+ if args.max is not None and args.skill != "atw-implement-all":
2545
+ raise ReviewError("--max 只属于 atw-implement-all")
2546
+ if not Path(args.task).is_dir():
2547
+ raise ReviewError(f"找不到任务目录:{args.task}")
2548
+ out = cmd_pick(args)
2549
+ return {**out, "notice": build_afk_notice(out, args.skill, args.branch, args.new_branch, args.max, args.task)}
2550
+
2551
+
2552
+ def _channel_args(atw: str, root: Path, channel: str, prompt: str, r: dict, timeout: str) -> list[str]:
2553
+ cmd = [
2554
+ atw, "channel", "run", channel,
2555
+ "--provider", r["tool"],
2556
+ "--as", "reviewer",
2557
+ "--cwd", str(root),
2558
+ "--model", r["model"],
2559
+ ]
2560
+ if r.get("effort"):
2561
+ cmd += ["--effort", r["effort"]]
2562
+ cmd += [
2563
+ "--read-only", "--clean", "--no-session", "--standard-speed",
2564
+ "--timeout", timeout,
2565
+ "--message-file", prompt,
2566
+ "--json",
2567
+ ]
2568
+ return cmd
2569
+
2570
+
2571
+ def _parse_run_result(stdout: str, stderr: str) -> tuple[dict, bool]:
2572
+ """channel 交回的结果对象,与它是不是 channel 真交回的(没交回时这里补一个 not-started,为 False)。"""
2573
+ for line in reversed(stdout.splitlines()):
2574
+ if line.strip():
2575
+ try:
2576
+ parsed = json.loads(line)
2577
+ except ValueError:
2578
+ break
2579
+ if isinstance(parsed, dict):
2580
+ return parsed, True
2581
+ break
2582
+ return {
2583
+ "status": "not-started",
2584
+ "text": None,
2585
+ "partial": False,
2586
+ "durationMs": 0,
2587
+ "costUsd": None,
2588
+ "costUnreported": True,
2589
+ "usage": None,
2590
+ "model": None,
2591
+ "instructionSources": None,
2592
+ "error": stderr.strip()[-2000:] or "atw channel run 没有交出结果对象",
2593
+ "errorKind": None,
2594
+ "errorStatus": None,
2595
+ "errorSubkind": None,
2596
+ }, False
2597
+
2598
+
2599
+ # channel 里 supervisor 报起不来、握手失败的报错开头:这时题目还没发给工具
2600
+ NOTHING_SENT_ERRORS = ("worker spawn failed: ", "handshake failed: ")
2601
+ # 要先握手、握手做完才收题目的工具:Codex 的 app-server 要等 `thread/start` 回了线程 id 才收题目,
2602
+ # 而那条回复必带 `model`(协议 `ThreadStartResponse` 的必填字段),channel 把它交在结果对象的 `model` 里。
2603
+ # 所以 channel 真交回了结果对象、`not-started` 且 `model` 为空时,握手没做完,题目没发出(如登录失效时握手前就退出)。
2604
+ # channel 没交回结果对象(被杀、崩了)或交回的带了 `model`(题目发出后才出的别的异常)都说不准。
2605
+ # Claude 没有握手,进程一起来就收题目;pi、omp 的握手回复不一定带模型名,这三种说不准。
2606
+ HANDSHAKE_BEFORE_PROMPT = ("codex",)
2607
+ # commander 解析参数出错时的报错开头(如 `error: unknown option '--read-only'`):这时 channel 还没做任何事,
2608
+ # 标准输出也没有结果对象;多半是 PATH 里的 atw 与项目的 ATW 版本不一致
2609
+ ARGS_REFUSED_ERROR = "error: "
2610
+
2611
+
2612
+ def _args_refused(stdout: str, stderr: str) -> bool:
2613
+ """`atw channel run` 在解析参数时就退出:没交结果对象,报错是 commander 的开头。"""
2614
+ return not stdout.strip() and stderr.lstrip().startswith(ARGS_REFUSED_ERROR)
2615
+
2616
+
2617
+ def _spent(result: dict, sent: str) -> str:
2618
+ """是否已花钱:工具报了正数花费或用量为 yes,报了 0 或确定题目没发出为 no,报不出为 unreported。"""
2619
+ cost = result.get("costUsd")
2620
+ if isinstance(cost, (int, float)) and cost > 0:
2621
+ return "yes"
2622
+ usage = result.get("usage")
2623
+ if isinstance(usage, dict) and any(isinstance(v, (int, float)) and v > 0 for v in usage.values()):
2624
+ return "yes"
2625
+ if sent == "no":
2626
+ return "no"
2627
+ if isinstance(cost, (int, float)) and not result.get("costUnreported"):
2628
+ return "no"
2629
+ return "unreported"
2630
+
2631
+
2632
+ NOT_READ_HEADING = re.compile(r"^#{1,6}[ \t]*not read[ \t]*$", re.IGNORECASE | re.MULTILINE)
2633
+ NOTHING_LEFT = {"none", "nothing", "n/a", "无", "没有"}
2634
+ # 「None」后面跟括注、破折号、冒号或标点再加一句说明,如 `None(均已完整读取)。`、`None — I read every file in full.`
2635
+ NOTHING_LEFT_WITH_NOTE = re.compile(
2636
+ r"(?:none|nothing|n/a|无|没有)[*_`]*\s*[((—–\-::,,;;.。](?P<note>.*)", re.IGNORECASE
2637
+ )
2638
+ # 说明里点了路径或写了例外、只看一部分,就不算全看完(如 `None, except src/a.py`、`没有,a.py 只看了一半`)
2639
+ NOTE_NAMES_PATH = re.compile(r"/|\b[\w-]+\.[A-Za-z0-9]{1,6}\b")
2640
+ NOTE_EXCEPTION = re.compile(
2641
+ r"\b(?:except|but|besides|apart from|other than|only|partial(?:ly)?|half|skip(?:ped)?)\b"
2642
+ r"|除|只看|没看|未看|一半|跳过|部分",
2643
+ re.IGNORECASE,
2644
+ )
2645
+
2646
+
2647
+ def _entry_file(line: str, names: set[str]) -> tuple[str | None, bool]:
2648
+ """一行没看的:开头是补丁里的路径(可带反引号、`a/` `b/` 前缀),后面可跟说明。返回(文件,是否只是一部分)。"""
2649
+ head, _, note = line.partition(" ")
2650
+ path = head.strip("`*_").rstrip(":,;")
2651
+ for cand in (path, path[2:] if path[:2] in ("a/", "b/") else None):
2652
+ if cand in names:
2653
+ return cand, bool(note.strip(" -—–:()`"))
2654
+ return None, False
2655
+
2656
+
2657
+ def _not_read_section(text: str | None) -> str | None:
2658
+ """答案末尾 `## Not read` 一节的正文;没写这一节为 None。"""
2659
+ heads = list(NOT_READ_HEADING.finditer(text or ""))
2660
+ if not heads:
2661
+ return None
2662
+ rest = (text or "")[heads[-1].end():]
2663
+ nxt = re.search(r"^#", rest, re.MULTILINE)
2664
+ return (rest[: nxt.start()] if nxt else rest).strip()
2665
+
2666
+
2667
+ def _not_read_entries(section: str) -> list[str]:
2668
+ """一节里的各条:去掉列表记号,空行不算。"""
2669
+ lines = (re.sub(r"^\s*(?:[-*+]|\d+[.)])\s+", "", raw).strip() for raw in section.splitlines())
2670
+ return [line for line in lines if line]
2671
+
2672
+
2673
+ def not_read(text: str | None, manifest: dict) -> dict | None:
2674
+ """复查员答案末尾 `## Not read` 一节:没写这一节为 None。
2675
+
2676
+ 一行一个补丁里的文件;认不出的行进 `unmatched`,这时没看的大小不明,`lines` 为 None(不是 0)。
2677
+ 只没看一部分的文件(路径后面有说明)按整个文件算,`linesAtMost` 为真。
2678
+ """
2679
+ section = _not_read_section(text)
2680
+ if section is None:
2681
+ return None
2682
+ out: dict = {"text": section, "files": [], "partFiles": [], "unmatched": [], "lines": 0,
2683
+ "linesAtMost": False, "suggestedGroups": None}
2684
+ if _says_nothing(section):
2685
+ return out
2686
+ by_file: dict[str, int] = manifest.get("linesByFile", {})
2687
+ names = set(by_file) | {x["path"] for x in manifest.get("pathOnly", [])}
2688
+ for line in _not_read_entries(section):
2689
+ f, part = _entry_file(line, names)
2690
+ if f is None:
2691
+ out["unmatched"].append(line)
2692
+ continue
2693
+ if f not in out["files"]:
2694
+ out["files"].append(f)
2695
+ if part and f not in out["partFiles"]:
2696
+ out["partFiles"].append(f)
2697
+ out["linesAtMost"] = bool(out["partFiles"])
2698
+ if out["unmatched"]:
2699
+ out["lines"] = None
2700
+ return out
2701
+ by_folder: dict[str, int] = {}
2702
+ for f in out["files"]:
2703
+ folder = posixpath.dirname(f) or "."
2704
+ by_folder[folder] = by_folder.get(folder, 0) + by_file.get(f, 0)
2705
+ out["lines"] = sum(by_folder.values())
2706
+ if out["lines"] > LINE_LIMIT:
2707
+ out["suggestedGroups"] = suggest_groups(by_folder, [])
2708
+ return out
2709
+
2710
+
2711
+ def log_not_read(text: str | None) -> dict | None:
2712
+ """日志复查的 `## Not read`:没有补丁可对,各条(按时间点的行、聊天时间段)原样收进 `parts`。
2713
+
2714
+ 写「无」/`None` 算全看了,`parts` 为空;没写这一节、或标题下面空着,都算没说,为 None。
2715
+ """
2716
+ section = _not_read_section(text)
2717
+ if not section:
2718
+ return None
2719
+ return {"text": section, "parts": [] if _says_nothing(section) else _not_read_entries(section)}
2720
+
2721
+
2722
+ def _outcome(result: dict, unread: dict | None, args_refused: bool = False, handshake_unfinished: bool = False) -> dict:
2723
+ """卡在哪一步、代码是否已发出、是否已花钱。"""
2724
+ status = result.get("status")
2725
+ if status == "done":
2726
+ # 写了没看的部分(认不出是哪些文件也算)就是跑完但没看完
2727
+ stage = "unread" if unread is not None and not _says_nothing(unread["text"]) else None
2728
+ sent = "yes"
2729
+ elif status == "not-started":
2730
+ # channel 拒了参数、起不来、握手失败,或 Codex 握手没做完,才确定题目没发出;
2731
+ # 其余工具一句话没说就退出的(例如收到题目后才拒)说不准
2732
+ stage = "not-started"
2733
+ nothing_sent = (
2734
+ args_refused
2735
+ or (result.get("error") or "").startswith(NOTHING_SENT_ERRORS)
2736
+ or handshake_unfinished
2737
+ )
2738
+ sent = "no" if nothing_sent else "maybe"
2739
+ else:
2740
+ # 超时或中途出错:题目已到模型手里,读没读补丁说不准
2741
+ stage, sent = "partway", "maybe"
2742
+ out = {"stage": stage, "codeSent": sent, "spent": _spent(result, sent), "notRead": unread}
2743
+ if status == "not-started" and args_refused:
2744
+ # 只在 channel 解析参数就退出时出现:多半是 PATH 里的 atw 与项目的 ATW 版本不一致
2745
+ out["argsRefused"] = True
2746
+ return out
2747
+
2748
+
2749
+ def _says_nothing(section: str) -> bool:
2750
+ s = section.strip("*_-`. \n")
2751
+ if s.lower() in NOTHING_LEFT:
2752
+ return True
2753
+ m = NOTHING_LEFT_WITH_NOTE.match(s) if "\n" not in s else None
2754
+ return m is not None and not NOTE_NAMES_PATH.search(m["note"]) and not NOTE_EXCEPTION.search(m["note"])
2755
+
2756
+
2757
+ def review_timeout() -> str:
2758
+ """复查时限 30 分钟;`ATW_REVIEW_TIMEOUT` 只给测试缩短用(`<N>s` 或 `<N>m`,不超过 30 分钟)。"""
2759
+ return f"{review_budget_s()}s" if os.environ.get("ATW_REVIEW_TIMEOUT") else f"{REVIEW_TIMEOUT_S // 60}m"
2760
+
2761
+
2762
+ def review_budget_s() -> int:
2763
+ """时限的秒数(日志复查重试时按它算剩下的时间)。"""
2764
+ raw = os.environ.get("ATW_REVIEW_TIMEOUT")
2765
+ if not raw:
2766
+ return REVIEW_TIMEOUT_S
2767
+ m = re.fullmatch(r"(\d+)(s|m)", raw.strip())
2768
+ seconds = int(m.group(1)) * (60 if m.group(2) == "m" else 1) if m else 0
2769
+ if not 0 < seconds <= REVIEW_TIMEOUT_S:
2770
+ raise ReviewError(f"ATW_REVIEW_TIMEOUT「{raw}」不对:只能缩短时限,写 <N>s 或 <N>m,不超过 30m")
2771
+ return seconds
2772
+
2773
+
2774
+ # 日志复查按失败原因自动重试(prd K 节):先认工具自己报的出错种类(channel 结果的 `errorKind`,原样),
2775
+ # 没有种类、或种类不在下表时才按报错文字归类(FAILURE_PATTERNS),认不出的算「原因看不出」。
2776
+ # Claude 的取值是 Agent SDK 的 `SDKAssistantMessageError`(0.3.276);Codex 的是 app-server v2 的 `codexErrorInfo`
2777
+ # (codex-cli 0.163.0 `codex app-server generate-json-schema` 导出的 `CodexErrorInfo`)。
2778
+ # `invalid_request`、`max_output_tokens`、`unknown`、`badRequest`、`other` 等说不清原因的不列,退回文字。
2779
+ ERROR_KIND_REASONS: dict[str, str] = {
2780
+ # Claude
2781
+ "model_not_found": "model-unavailable",
2782
+ "authentication_failed": "not-logged-in",
2783
+ "oauth_org_not_allowed": "not-logged-in",
2784
+ "cloud_credential_error": "not-logged-in",
2785
+ "verification_required": "not-logged-in",
2786
+ "account_on_hold": "not-logged-in",
2787
+ "billing_error": "quota-exhausted",
2788
+ "rate_limit": "transient",
2789
+ "overloaded": "transient",
2790
+ "server_error": "transient",
2791
+ # Codex
2792
+ "unauthorized": "not-logged-in",
2793
+ "usageLimitExceeded": "quota-exhausted",
2794
+ "sessionBudgetExceeded": "quota-exhausted",
2795
+ "contextWindowExceeded": "too-long",
2796
+ "rateLimitExceeded": "transient",
2797
+ "serverOverloaded": "transient",
2798
+ "internalServerError": "transient",
2799
+ "httpConnectionFailed": "transient",
2800
+ "responseStreamConnectionFailed": "transient",
2801
+ "responseStreamDisconnected": "transient",
2802
+ "responseTooManyFailedAttempts": "transient",
2803
+ }
2804
+ # Claude 的 `rate_limit` 也用于包月额度用完,这时细分种类(`errorSubkind`,Claude 的 `api_error`)是下面两个之一。
2805
+ # Claude 的 SDK 把 `api_error` 标为内部字段:改名后这里认不出,退回按 `rate_limit` 当一时出错重试
2806
+ QUOTA_SUBKINDS = frozenset({"usage_limit_reached", "model_requires_usage_credits"})
2807
+ FAILURE_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
2808
+ # 只认启动复查员工具本身失败:报错里点名的是工具的可执行文件
2809
+ ("not-installed", re.compile(
2810
+ r"\b(?:claude|codex|pi|omp)\b[^\n]{0,40}(?:command not found|not installed|\bENOENT\b)"
2811
+ r"|(?:command not found|spawn)\b[^\n]{0,40}\b(?:claude|codex|pi|omp)\b", re.I)),
2812
+ ("not-logged-in", re.compile(
2813
+ r"not logged in|/login\b|log ?in required|unauthori[sz]ed|\b401\b|authentication|invalid api key"
2814
+ # pi 登录过期:OAuth 刷新被拒(工单 13 实测 `OAuth refresh failed … "invalid_grant"`)
2815
+ r"|invalid x-api-key|oauth refresh failed|invalid_grant", re.I)),
2816
+ ("quota-exhausted", re.compile(
2817
+ r"usage limit|quota|credit balance|out of credits|billing|plan limit|insufficient[_ ]quota", re.I)),
2818
+ ("model-unavailable", re.compile(
2819
+ r"model\b[^\n]{0,60}\b(?:not found|does not exist|not available|unavailable|not supported)"
2820
+ # Claude Code 2.1.302:「There's an issue with the selected model (…). It may not exist or you may not have access to it.」
2821
+ r"|selected model\b[^\n]{0,80}\bmay not exist"
2822
+ r"|unknown model|invalid model|no such model|model_not_found", re.I)),
2823
+ ("too-long", re.compile(
2824
+ r"too long|context[_ ]length|context window|maximum context|too many tokens|token limit"
2825
+ r"|request too large|input is too large|\b413\b", re.I)),
2826
+ ("transient", re.compile(
2827
+ r"\bE(?:CONNRESET|CONNREFUSED|TIMEDOUT|AI_AGAIN|NOTFOUND|PIPE)\b|socket hang up|network"
2828
+ r"|connection (?:error|reset|refused|closed|lost)|disconnected|fetch failed|overloaded"
2829
+ r"|rate[ _-]?limit|too many requests|\b429\b|internal server error|bad gateway"
2830
+ # 5xx 只在状态码的上下文里认,免得「500 行」之类被当成对方服务出错
2831
+ r"|\b(?:http|status(?: code)?|api error|error)\W{0,3}5\d\d\b|\b5\d\d (?:internal|bad gateway|service|gateway)"
2832
+ r"|service unavailable|gateway timeout|temporarily unavailable|try again later", re.I)),
2833
+ ("tool-error", re.compile(
2834
+ r"unknown (?:option|argument|flag)|unrecognized (?:option|argument)|unexpected argument"
2835
+ r"|invalid (?:argument|option|flag|value)|unsupported|\bversion\b|usage:", re.I)),
2836
+ )
2837
+ MAX_RETRIES = 2
2838
+ # 一个复查员的全部尝试合计不超过时限;剩下不到时限的十分之一(至少 1 秒)就不再重试
2839
+ MIN_RETRY_SHARE = 10
2840
+
2841
+
2842
+ def classify_failure(result: dict, args_refused: bool) -> str | None:
2843
+ """跑成为 None;超时、channel 拒参数直接认;再按工具报的出错种类;最后按报错文字归类,认不出的为 unknown。"""
2844
+ status = result.get("status")
2845
+ if status == "done":
2846
+ return None
2847
+ if status == "timeout":
2848
+ return "timeout"
2849
+ if args_refused:
2850
+ return "tool-error"
2851
+ kind = result.get("errorKind")
2852
+ if kind == "rate_limit" and result.get("errorSubkind") in QUOTA_SUBKINDS:
2853
+ return "quota-exhausted"
2854
+ if isinstance(kind, str) and kind in ERROR_KIND_REASONS:
2855
+ return ERROR_KIND_REASONS[kind]
2856
+ text = result.get("error") or ""
2857
+ return next((reason for reason, pattern in FAILURE_PATTERNS if pattern.search(text)), "unknown")
2858
+
2859
+
2860
+ def _retry_with(reason: str | None, attempts: list[dict], material: str) -> str | None:
2861
+ """下一次用哪份材料(full / short);不再试时为 None。"""
2862
+ if len(attempts) > MAX_RETRIES:
2863
+ return None
2864
+ if reason == "transient":
2865
+ return material
2866
+ if reason == "too-long":
2867
+ # 已经是各 5 行的了:没有更短的可发
2868
+ return "short" if material == "full" else None
2869
+ if reason == "unknown" and not any(a["reason"] == "unknown" for a in attempts[:-1]):
2870
+ return material
2871
+ return None
2872
+
2873
+
2874
+ def _merge(values: list[str], order: tuple[str, ...]) -> str:
2875
+ """几次尝试合起来:按 order 取最先出现的(例如有一次已发出就算已发出)。"""
2876
+ return next(v for v in order if v in values)
2877
+
2878
+
2879
+ def _totals(attempts: list[dict]) -> dict:
2880
+ """各次尝试合计的用时与花费;有一次没报花费,costUnreported 为真(costUsd 只是报了的那几次之和)。"""
2881
+ costs = [a["costUsd"] for a in attempts if isinstance(a["costUsd"], (int, float))]
2882
+ return {
2883
+ "durationMs": sum(a["durationMs"] or 0 for a in attempts),
2884
+ "costUsd": sum(costs) if costs else None,
2885
+ "costUnreported": any(a["costUnreported"] or not isinstance(a["costUsd"], (int, float)) for a in attempts),
2886
+ }
2887
+
2888
+
2889
+ def cmd_run(args: argparse.Namespace) -> dict:
2890
+ timeout = review_timeout()
2891
+ budget = review_budget_s()
2892
+ root = repo_root()
2893
+ out_dir = root / RUNTIME_SUBDIR / args.id
2894
+ manifest_path = out_dir / "manifest.json"
2895
+ if not manifest_path.is_file():
2896
+ raise ReviewError(f"找不到这次复查的材料:{_rel(root, out_dir)}(先跑 prepare)")
2897
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
2898
+ log_review = manifest.get("kind") == "log-review"
2899
+ channels: list[str] = []
2900
+ channels_of: dict[int, list[str]] = {}
2901
+ atw = shutil.which("atw")
2902
+ started = time.monotonic()
2903
+
2904
+ def remove_channel(ch: str) -> None:
2905
+ subprocess.run([atw or "atw", "channel", "rm", ch], cwd=root, capture_output=True, text=True)
2906
+
2907
+ def attempt(i: int, r: dict, prompt: str, n: int, limit: str) -> tuple[dict, dict, bool]:
2908
+ """跑一次:第 n 次重试用自己的临时频道(`…-r<n>`)。返回结果对象、卡在哪一步等、channel 是否拒了参数。"""
2909
+ ch = f"xmr-{args.id}-{i + 1}" + (f"-r{n}" if n else "")
2910
+ channels.append(ch)
2911
+ channels_of.setdefault(i, []).append(ch)
2912
+ proc = subprocess.run(
2913
+ _channel_args(atw or "atw", root, ch, prompt, r, limit),
2914
+ cwd=root,
2915
+ capture_output=True,
2916
+ text=True,
2917
+ encoding="utf-8",
2918
+ errors="replace",
2919
+ )
2920
+ res, returned = _parse_run_result(proc.stdout, proc.stderr)
2921
+ refused = _args_refused(proc.stdout, proc.stderr)
2922
+ handshake_unfinished = returned and r["tool"] in HANDSHAKE_BEFORE_PROMPT and not res.get("model")
2923
+ unread = log_not_read(res.get("text")) if log_review else not_read(res.get("text"), manifest)
2924
+ return res, _outcome(res, unread, refused, handshake_unfinished), refused
2925
+
2926
+ def review(i: int, r: dict) -> dict:
2927
+ if not log_review:
2928
+ res, outcome, _ = attempt(i, r, manifest["prompt"], 0, timeout)
2929
+ return {**r, **outcome, "result": res}
2930
+ material, attempts, stopped_by = "full", [], None
2931
+ began = time.monotonic()
2932
+ limit = timeout
2933
+ while True:
2934
+ prompt = manifest["prompt"] if material == "full" else manifest["promptShort"]
2935
+ res, outcome, refused = attempt(i, r, prompt, len(attempts), limit)
2936
+ reason = classify_failure(res, refused)
2937
+ attempts.append({
2938
+ "material": material,
2939
+ "status": res.get("status"),
2940
+ "reason": reason,
2941
+ "error": res.get("error"),
2942
+ "durationMs": res.get("durationMs"),
2943
+ "costUsd": res.get("costUsd"),
2944
+ "costUnreported": bool(res.get("costUnreported")),
2945
+ "timeout": limit,
2946
+ "codeSent": outcome["codeSent"],
2947
+ "spent": outcome["spent"],
2948
+ })
2949
+ nxt = _retry_with(reason, attempts, material)
2950
+ if nxt is None:
2951
+ break
2952
+ # prd K 节:每个复查员最多 30 分钟,重试只用剩下的时间
2953
+ remaining = int(budget - (time.monotonic() - began))
2954
+ if remaining < max(1, budget // MIN_RETRY_SHARE):
2955
+ stopped_by = "time-limit"
2956
+ break
2957
+ # 没跑完的频道 channel 会留着、占着每个项目有限的在跑助手名额:重试前先删掉
2958
+ remove_channel(channels_of[i][-1])
2959
+ material, limit = nxt, f"{remaining}s"
2960
+ # 前几次可能已发出、已花钱:合起来算
2961
+ outcome["codeSent"] = _merge([a["codeSent"] for a in attempts], ("yes", "maybe", "no"))
2962
+ outcome["spent"] = _merge([a["spent"] for a in attempts], ("yes", "unreported", "no"))
2963
+ return {**r, **outcome, "result": res, "failureReason": reason, "retries": len(attempts) - 1,
2964
+ "retryStoppedBy": stopped_by, "attempts": attempts, "total": _totals(attempts)}
2965
+
2966
+ try:
2967
+ if atw is None:
2968
+ raise ReviewError("找不到 atw 命令(PATH 里没有)")
2969
+ results: list[dict] = [{} for _ in manifest["reviewers"]]
2970
+
2971
+ def collect(i: int, r: dict) -> None:
2972
+ results[i] = review(i, r)
2973
+
2974
+ threads = [threading.Thread(target=collect, args=(i, r)) for i, r in enumerate(manifest["reviewers"])]
2975
+ for t in threads:
2976
+ t.start()
2977
+ for t in threads:
2978
+ t.join()
2979
+ finally:
2980
+ # 跑完的频道 channel 自己删了;没跑完的被留着查看,这里一并删掉(原始报错已在结果里)
2981
+ if atw is not None:
2982
+ for ch in channels:
2983
+ remove_channel(ch)
2984
+ shutil.rmtree(out_dir, ignore_errors=True)
2985
+ if log_review:
2986
+ return {
2987
+ "id": args.id,
2988
+ "kind": "log-review",
2989
+ "durationMs": int((time.monotonic() - started) * 1000),
2990
+ "reviewers": results,
2991
+ }
2992
+ return {
2993
+ "id": args.id,
2994
+ # 重试或换人时交给 prepare 的 --expect-base / --expect-patch,核对材料没变
2995
+ "baseCommit": manifest["baseCommit"],
2996
+ "patchHash": manifest["patchHash"],
2997
+ # 复查报告里写上它,复核时交给 prepare --recheck
2998
+ "snapshot": manifest.get("snapshot"),
2999
+ "durationMs": int((time.monotonic() - started) * 1000),
3000
+ "reviewers": results,
3001
+ }
3002
+
3003
+
3004
+ # 打码规则(prd N 节首批):先认有固定样子的,再认赋给 api_key、secret、token、password 一类名字的长串
3005
+ _PEM_BLOCK = (
3006
+ r"-----BEGIN[A-Z0-9 ]*PRIVATE KEY-----"
3007
+ # 有结尾标记就整块到结尾;半截回答没有结尾时,连同后面整行都像密钥内容的(可带补丁的 +、- 或引用的 >),
3008
+ # 到空行或别的文字为止,最后一行再短也算
3009
+ r"(?:.*?-----END[A-Z0-9 ]*PRIVATE KEY-----|(?:\n[ \t>+\-]*[A-Za-z0-9+/=]+(?=\n|\Z))*)"
3010
+ )
3011
+ SECRET_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
3012
+ ("私钥", re.compile(_PEM_BLOCK, re.DOTALL)),
3013
+ ("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
3014
+ ("Anthropic 密钥", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}")),
3015
+ ("sk- 密钥", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}")),
3016
+ ("GitHub 令牌", re.compile(r"\b(?:ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,})")),
3017
+ # xoxb-、xoxp-、xoxe-、xoxe.xoxp- 等,xox 开头的都算
3018
+ ("Slack 令牌", re.compile(r"\bxox[A-Za-z0-9.]*-[A-Za-z0-9-]{10,}")),
3019
+ ("AWS 访问密钥", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
3020
+ ("Google API 密钥", re.compile(r"\bAIza[0-9A-Za-z_-]{35}")),
3021
+ )
3022
+ _NAMED_SECRET = re.compile(
3023
+ r"\b(?P<name>[A-Za-z0-9_.-]*?(?:api[_-]?key|secret|token|passw(?:or)?d)[A-Za-z0-9_]*)[\"']?"
3024
+ r"[ \t]*(?:=>|:=|:|=)[ \t]*"
3025
+ # 引号里的到引号为止,没引号的到空白或标点为止;后面紧跟 ( 的是函数调用
3026
+ r"(?:(?P<q>[\"'])(?P<quoted>[^\"'\s]{16,})(?P=q)|(?P<bare>[^\s\"'`,;()\[\]{}<>]{16,})(?![(\w]))",
3027
+ re.IGNORECASE,
3028
+ )
3029
+ # 赋值右边是代码而不是值的:点连起来的引用、网址、${…} 模板、全大写下划线常量名
3030
+ _CODE_LIKE = re.compile(
3031
+ r"[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+"
3032
+ r"|[A-Za-z][A-Za-z0-9+.-]*://\S*"
3033
+ r"|\$\{?[\w.]+\}?"
3034
+ r"|[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+"
3035
+ )
3036
+ _HEADING = re.compile(r"^#{1,6}[ \t]+(.+?)[ \t#]*$")
3037
+
3038
+
3039
+ def _named_secret_type(name: str) -> str:
3040
+ low = name.lower()
3041
+ if "passw" in low:
3042
+ return "密码"
3043
+ return "令牌" if "token" in low else "密钥"
3044
+
3045
+
3046
+ def _secret_value(value: str) -> bool:
3047
+ """赋给密钥类名字的长串是不是值:要有字母,还要有数字或 -_. 以外的符号;像代码的不算。"""
3048
+ if _CODE_LIKE.fullmatch(value) or not re.search(r"[A-Za-z]", value):
3049
+ return False
3050
+ return bool(re.search(r"[0-9]|[^A-Za-z0-9_.\-]", value))
3051
+
3052
+
3053
+ def redact(text: str, before: str = "") -> tuple[str, list[dict]]:
3054
+ """像密钥的内容整段换成「[已打码:<类型>]」;返回打码后的文字与每处的类型、行号、所在小节。
3055
+
3056
+ 值很短、像代码(`process.env.X`、`os.environ.API_KEY_V2`、网址、常量名)、只有字母或一串数字的不算,
3057
+ 免得误伤普通变量。`before` 是接在它前面、已经打过码的文字(追加时的原报告):行号与小节接着它算。
3058
+ """
3059
+ types: list[str] = []
3060
+ return _place_marks(_mark_secrets(text, types), types, before)
3061
+
3062
+
3063
+ def _mark_secrets(text: str, types: list[str]) -> str:
3064
+ """打码第一步:每处像密钥的换成一行内的记号(类型追加进 `types`),由 `_place_marks` 换成占位。
3065
+
3066
+ 分两步是为日志复查材料:先整条打码、再截短,最后照截短后的材料算行号(prd F 节)。
3067
+ """
3068
+
3069
+ def mark(kind: str) -> str:
3070
+ types.append(kind)
3071
+ return f"\x00{len(types) - 1}\x00"
3072
+
3073
+ for kind, pattern in SECRET_PATTERNS:
3074
+ text = pattern.sub(lambda m, k=kind: mark(k), text)
3075
+
3076
+ def named(m: re.Match[str]) -> str:
3077
+ part = "quoted" if m.group("quoted") is not None else "bare"
3078
+ if not _secret_value(m.group(part)):
3079
+ return m.group(0)
3080
+ start, end = m.start(part) - m.start(), m.end(part) - m.start()
3081
+ whole = m.group(0)
3082
+ return whole[:start] + mark(_named_secret_type(m.group("name"))) + whole[end:]
3083
+
3084
+ return _NAMED_SECRET.sub(named, text)
3085
+
3086
+
3087
+ def _place_marks(text: str, types: list[str], before: str = "") -> tuple[str, list[dict]]:
3088
+ """打码第二步:记号换成「[已打码:<类型>]」,记下每处的类型、行号、所在小节。"""
3089
+ found: list[dict] = []
3090
+ section = None
3091
+ in_fence = False
3092
+ prior = before.split("\n")[:-1]
3093
+ for line in prior:
3094
+ if line.lstrip().startswith(("```", "~~~")):
3095
+ in_fence = not in_fence
3096
+ heading = None if in_fence else _HEADING.match(line)
3097
+ if heading:
3098
+ section = heading.group(1)
3099
+ out_lines = []
3100
+ for number, line in enumerate(text.split("\n"), start=len(prior) + 1):
3101
+ marks = [types[int(i)] for i in re.findall(r"\x00(\d+)\x00", line)]
3102
+ line = re.sub(r"\x00(\d+)\x00", lambda m: f"[已打码:{types[int(m.group(1))]}]", line)
3103
+ if line.lstrip().startswith(("```", "~~~")):
3104
+ in_fence = not in_fence
3105
+ heading = None if in_fence else _HEADING.match(line)
3106
+ if heading:
3107
+ section = heading.group(1)
3108
+ found += [{"type": kind, "line": number, "section": section} for kind in marks]
3109
+ out_lines.append(line)
3110
+ return "\n".join(out_lines), found
3111
+
3112
+
3113
+ REPORT_SUBDIR = "cross-model-review"
3114
+ # 日志复查的报告单独放(prd K 节、访谈 Q13):放进 cross-model-review/ 会压掉验收时的跨模型复查提醒
3115
+ LOG_REVIEW_SUBDIR = "log-review"
3116
+
3117
+
3118
+ def _report_path(task: Path, subdir: str = REPORT_SUBDIR) -> Path:
3119
+ """任务目录下 `<subdir>/<日期>-<时分秒>.md`;同一秒已有就加 `-2`、`-3`。"""
3120
+ folder = task / subdir
3121
+ stem = time.strftime("%Y-%m-%d-%H%M%S")
3122
+ path, n = folder / f"{stem}.md", 2
3123
+ while path.exists():
3124
+ path, n = folder / f"{stem}-{n}.md", n + 1
3125
+ return path
3126
+
3127
+
3128
+ def cmd_report(args: argparse.Namespace) -> dict:
3129
+ draft = Path(args.file)
3130
+ # 出错时草稿(打码前的原文)留着:修好后重跑同一条命令,或不再跑就删掉它
3131
+ kept = f";草稿 {draft} 没删,里面是打码前的原文:修好后重跑同一条命令,不再跑就删掉它"
3132
+ if args.task and args.append:
3133
+ raise ReviewError("--task 与 --append 只能给一个" + kept)
3134
+ if args.log_review and not args.task:
3135
+ raise ReviewError("--log-review 要与 --task 一起给:报告存进那个任务的 log-review/" + kept)
3136
+ try:
3137
+ text = draft.read_text(encoding="utf-8")
3138
+ except OSError as e:
3139
+ raise ReviewError(f"读不了报告草稿 {draft}:{e}") from e
3140
+ before = ""
3141
+ target: Path | None = None
3142
+ if args.append:
3143
+ target = Path(args.append)
3144
+ try:
3145
+ before = target.read_text(encoding="utf-8")
3146
+ except OSError as e:
3147
+ raise ReviewError(f"读不了要追加的报告 {target}:{e}{kept}") from e
3148
+ if before and not before.endswith("\n"):
3149
+ text = "\n" + text
3150
+ elif args.task:
3151
+ task = Path(args.task)
3152
+ if not task.is_dir():
3153
+ raise ReviewError(f"任务目录不存在:{task}{kept}")
3154
+ target = _report_path(task, LOG_REVIEW_SUBDIR if args.log_review else REPORT_SUBDIR)
3155
+ text, found = redact(text, before)
3156
+ saved = None
3157
+ if target is not None:
3158
+ try:
3159
+ target.parent.mkdir(exist_ok=True)
3160
+ with target.open("a" if args.append else "x", encoding="utf-8") as f:
3161
+ f.write(text)
3162
+ except OSError as e:
3163
+ raise ReviewError(f"写不了复查报告 {target}:{e}{kept}") from e
3164
+ saved = str(target)
3165
+ # 打码后的文字已存好(或不用存)才删草稿
3166
+ draft.unlink()
3167
+ return {"text": text, "redactedCount": len(found), "redactions": found, "saved": saved}
3168
+
3169
+
3170
+ def _hook_notice_path() -> Path | None:
3171
+ """`.atw/` 所在目录(与钩子 show-notice.py 同一个找法),不是 `repo_root()` 的 git 顶层;不在 ATW 项目里为 None。"""
3172
+ root = get_repo_root()
3173
+ return root / HOOK_NOTICE if (root / ".atw").is_dir() else None
3174
+
3175
+
3176
+ def clear_hook_notice() -> None:
3177
+ """删掉上一次写给钩子的交代:这次停下、出错或参数不对时,钩子不会把旧的当成这次的显示。"""
3178
+ path = _hook_notice_path()
3179
+ if path is None:
3180
+ return
3181
+ try:
3182
+ path.unlink(missing_ok=True)
3183
+ except OSError as e:
3184
+ print(f"提醒:删不了上一次给钩子的交代 {path}:{e}", file=sys.stderr)
3185
+
3186
+
3187
+ def write_hook_notice(command: str, result: dict, lang: str | None) -> None:
3188
+ """有 `notice` 时写给钩子:给了 `--lang` 只写那一种语言,不给时中、英两段(中文在前,空一行)。
3189
+
3190
+ 带上这次对话的 ATW 会话编号(`resolve_context_key`,Claude Code 上来自 `CLAUDE_CODE_SESSION_ID`),
3191
+ 钩子只在同一个对话里显示;拿不到编号时为 null,钩子照旧显示。
3192
+ """
3193
+ path = _hook_notice_path()
3194
+ notice = result.get("notice")
3195
+ if path is None or not notice:
3196
+ return
3197
+ text = notice[lang] if lang else f"{notice['zh']}\n\n{notice['en']}"
3198
+ saved = {"command": command, "lang": lang, "session": resolve_context_key(), "text": text}
3199
+ try:
3200
+ path.parent.mkdir(parents=True, exist_ok=True)
3201
+ path.write_text(json.dumps(saved, ensure_ascii=False), encoding="utf-8")
3202
+ except OSError as e:
3203
+ # 钩子没东西可显示,也就不会告诉 AI 已显示过:AI 照 skill 自己原样贴出
3204
+ print(f"提醒:写不了给钩子的交代 {path}:{e}", file=sys.stderr)
3205
+
3206
+
3207
+ def main(argv: list[str] | None = None) -> int:
3208
+ configure_encoding()
3209
+ argv = sys.argv[1:] if argv is None else argv
3210
+ # 解析参数之前就删:参数不对退出 2 时也不留上一次的
3211
+ if argv and argv[0] in NOTICE_COMMANDS:
3212
+ clear_hook_notice()
3213
+ p = argparse.ArgumentParser(description="ATW 跨模型复查:冻结补丁、组材料、同时跑全部复查员")
3214
+ sub = p.add_subparsers(dest="command", required=True)
3215
+ pp = sub.add_parser("prepare", help="冻结补丁、组材料与题目")
3216
+ pp.add_argument("--since", help="用户给的起点(提交、分支或标签);不给时取任务的 base_branch 或仓库默认主线")
3217
+ pp.add_argument("--path", action="append", help="只看这个文件夹或文件,可多次")
3218
+ pp.add_argument("--whole", action="store_true", help="超过 500 行时用户选了整段看")
3219
+ pp.add_argument("--group", action="append", help='用户选了分开看:一组一个,"<文件夹或文件>,<文件夹或文件>"')
3220
+ pp.add_argument("--questions", required=True, help="题目文件")
3221
+ pp.add_argument("--reviewer", action="append", required=True, help='"<工具> model=<模型> [effort=<强度>]"')
3222
+ pp.add_argument("--task", help="任务目录")
3223
+ pp.add_argument("--intent-file", help="没有 prd.md 时的意图")
3224
+ pp.add_argument("--recheck", help="复核:上次复查报告里的快照编号,只看那之后的改动")
3225
+ pp.add_argument("--picked-file", help="复核:上次报告里用户选去改的条目(与 --recheck 一起给)")
3226
+ pp.add_argument("--expect-base", help="重试或换人时:第一次 run 给出的 baseCommit")
3227
+ pp.add_argument("--expect-patch", help="重试或换人时:第一次 run 给出的 patchHash")
3228
+ pp.add_argument("--pick", help="这一轮 pick 输出的 JSON 对象,原样照抄;开跑前交代据它写同家、跳过与点名的提醒")
3229
+ pp.add_argument("--lang", choices=("zh", "en"), help="Claude Code:钩子原样显示给用户的那段用哪种语言(用户的语言,其他语言给 en);不给时中英两段都显示")
3230
+ pl = sub.add_parser("prepare-log", help="日志复查:组决定日志与聊天记录的材料并打码(不冻结补丁)")
3231
+ pl.add_argument("--questions", required=True, help="日志复查的题目文件")
3232
+ pl.add_argument("--reviewer", action="append", required=True, help='"<工具> model=<模型> [effort=<强度>]"')
3233
+ pl.add_argument("--task", required=True, help="任务目录(决定日志在里面)")
3234
+ pl_scope = pl.add_mutually_exclusive_group()
3235
+ pl_scope.add_argument("--whole", action="store_true", help="用户要求时看整份日志与各次运行的聊天记录;默认只看最近一次运行")
3236
+ pl_scope.add_argument(
3237
+ "--session", nargs="?", const=CURRENT_SESSION, metavar="对话编号",
3238
+ help="只看这个对话的运行;不带值时是这次对话(无人值守收尾用);日志里没有它的运行时停下(stop: no-run)",
3239
+ )
3240
+ pl.add_argument("--pick", help="同 prepare:这一轮 pick 输出的 JSON 对象,原样照抄")
3241
+ pl.add_argument("--lang", choices=("zh", "en"), help="同 prepare")
3242
+ pr = sub.add_parser("run", help="同时跑全部复查员,收完结果后清理")
3243
+ pr.add_argument("id", help="prepare 给出的编号")
3244
+ pf = sub.add_parser("family", help="按模型名字认家")
3245
+ pf.add_argument("model", help="模型名")
3246
+ pf.add_argument("--tool", choices=TOOLS, help="模型所在的工具(Claude Code 的简称只在 claude 时算)")
3247
+ sub.add_parser("probe", help="不调模型地查四个工具")
3248
+ sub.add_parser("settings", help="读复查员设置(~/.atw/reviewers.yaml,ATW_REVIEWERS_FILE 可改)")
3249
+ for name, desc in (("check", "不写设置,只验证复查员能不能用"), ("save", "验证全部能用后写入设置(设成默认)")):
3250
+ pc = sub.add_parser(name, help=desc)
3251
+ pc.add_argument("--reviewer", action="append", required=True, help='"<工具> [model=<模型>] [effort=<强度>]"')
3252
+ pk = sub.add_parser("pick", help="判宿主与当前 AI,定复查员(点名优先,否则自动挑一个)")
3253
+ pk.add_argument("--reviewer", action="append", help='当场点名的 "<工具> [model=<模型>] [effort=<强度>]"')
3254
+ pk.add_argument("--model-line", help="Claude Code 与 omp:系统提示里写模型的那一行,原样照抄")
3255
+ pk.add_argument("--author", action="append", help="用户说明的作者:家名或模型名,可多次")
3256
+ pk.add_argument("--auto", action="store_true", help="不看设置,直接自动挑一个")
3257
+ pk.add_argument(
3258
+ "--settings-only", action="store_true",
3259
+ help="无人值守的日志复查:只用设置里的复查员,不自动挑、不换人、不弹向导;用不成时在 unavailable 给原因",
3260
+ )
3261
+ pk.add_argument(
3262
+ "--failed", action="append", choices=TOOLS,
3263
+ help="这次跑不起来的复查员的工具,可多次:自动挑出的给出下一个合格的,点名或设置里的不自动换人",
3264
+ )
3265
+ pa = sub.add_parser("afk-notice", help="--afk 开跑提醒:同 pick --settings-only,另给写好的提醒全文")
3266
+ pa.add_argument("--skill", required=True, choices=("atw-implement-all", "atw-implement-spec"), help="哪个连做命令")
3267
+ pa.add_argument("--branch", required=True, help="这次运行的分支(atw-implement-spec 为集成分支)")
3268
+ pa.add_argument("--new-branch", action="store_true", help="分支是这次运行新开的")
3269
+ pa.add_argument("--max", type=int, help="用户给的 --max N(只属于 atw-implement-all),写进重新开跑的命令")
3270
+ pa.add_argument("--task", required=True, help="任务目录(「等你处理」清单在里面)")
3271
+ pa.add_argument("--model-line", help="同 pick")
3272
+ pa.add_argument("--author", action="append", help="同 pick")
3273
+ pa.add_argument("--lang", choices=("zh", "en"), help="同 prepare")
3274
+ pa.set_defaults(settings_only=True, reviewer=None, auto=False, failed=None)
3275
+ prp = sub.add_parser("report", help="给复查报告打码;有任务时存进任务目录的 cross-model-review/")
3276
+ prp.add_argument("--file", required=True, help="报告草稿(存好后删掉;出错时留着)")
3277
+ prp.add_argument("--task", help="任务目录;不给时只打码、不写文件")
3278
+ prp.add_argument("--append", help="已存的复查报告:打码后追加到它末尾(用户选去改的条目)")
3279
+ prp.add_argument("--log-review", action="store_true", help="日志复查的报告:存进任务目录的 log-review/(要与 --task 一起给)")
3280
+ sub.add_parser("host", help="主 AI 的命令在不在宿主的围栏里、能否申请到围栏外跑")
3281
+ pw = sub.add_parser("wizard", help="设置向导要列的:四个工具能不能用、哪家、是否与作者同家、推荐哪个")
3282
+ pw.add_argument("--model-line", help="同 pick")
3283
+ pw.add_argument("--author", action="append", help="同 pick")
3284
+ args = p.parse_args(argv)
3285
+ commands = {
3286
+ "pick": cmd_pick,
3287
+ "afk-notice": cmd_afk_notice,
3288
+ "prepare": cmd_prepare,
3289
+ "prepare-log": cmd_prepare_log,
3290
+ "run": cmd_run,
3291
+ "family": lambda a: {"model": a.model, "families": families_of(a.model, a.tool)},
3292
+ "probe": lambda a: {"tools": [probe_tool(t) for t in AUTO_PICK_ORDER]},
3293
+ "settings": lambda a: read_settings(),
3294
+ "check": lambda a: {"reviewers": [verify_reviewer(_parse_wanted(s)) for s in a.reviewer]},
3295
+ "save": cmd_save,
3296
+ "wizard": cmd_wizard,
3297
+ "host": lambda a: host_sandbox(),
3298
+ "report": cmd_report,
3299
+ }
3300
+ try:
3301
+ result = commands[args.command](args)
3302
+ except ReviewError as e:
3303
+ print(f"错误:{e}", file=sys.stderr)
3304
+ return EXIT_ERROR
3305
+ if args.command in NOTICE_COMMANDS:
3306
+ write_hook_notice(args.command, result, args.lang)
3307
+ print(json.dumps(result, ensure_ascii=False))
3308
+ return EXIT_OK
3309
+
3310
+
3311
+ if __name__ == "__main__":
3312
+ sys.exit(main())