@chinhae/atw-cli 0.6.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 (241) 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.7.0.json +1378 -0
  98. package/dist/templates/atw/definition-of-done.md +65 -0
  99. package/dist/templates/atw/gitignore.txt +7 -0
  100. package/dist/templates/atw/index.d.ts +11 -0
  101. package/dist/templates/atw/index.d.ts.map +1 -1
  102. package/dist/templates/atw/index.js +22 -0
  103. package/dist/templates/atw/index.js.map +1 -1
  104. package/dist/templates/atw/scripts/common/active_task.py +2 -2
  105. package/dist/templates/atw/scripts/common/workflow_phase.py +33 -0
  106. package/dist/templates/atw/scripts/cross_model_review.py +3312 -0
  107. package/dist/templates/atw/scripts/decision_log.py +2002 -0
  108. package/dist/templates/atw/scripts/floor_guard.py +642 -0
  109. package/dist/templates/atw/scripts/task.py +25 -6
  110. package/dist/templates/atw/scripts/tickets.py +480 -10
  111. package/dist/templates/atw/workflow-general.md +6 -1
  112. package/dist/templates/atw/workflow.md +346 -26
  113. package/dist/templates/claude/settings.json +12 -0
  114. package/dist/templates/codex/hooks/session-start.py +2 -2
  115. package/dist/templates/common/bundled-skills/atw-askme/SKILL.md +1 -1
  116. package/dist/templates/common/bundled-skills/atw-askme-with-docs/SKILL.md +1 -1
  117. package/dist/templates/common/bundled-skills/atw-channel/SKILL.md +5 -2
  118. package/dist/templates/common/bundled-skills/atw-channel/references/command-reference.md +44 -5
  119. package/dist/templates/common/bundled-skills/atw-channel/references/progress-debugging.md +9 -0
  120. package/dist/templates/common/bundled-skills/atw-channel/references/workers.md +48 -4
  121. package/dist/templates/common/bundled-skills/atw-code-review/SKILL.md +78 -3
  122. package/dist/templates/common/bundled-skills/atw-codebase-design/SKILL.md +2 -2
  123. package/dist/templates/common/bundled-skills/atw-codebase-design/{DESIGN-IT-TWICE.md → design-it-twice.md} +3 -3
  124. package/dist/templates/common/bundled-skills/atw-cross-model-review/SKILL.md +361 -0
  125. package/dist/templates/common/bundled-skills/atw-cross-model-review/agents/openai.yaml +5 -0
  126. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/four-tiers.md +42 -0
  127. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/log-review-questions.md +38 -0
  128. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/questions.md +54 -0
  129. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/recheck-questions.md +42 -0
  130. package/dist/templates/common/bundled-skills/atw-cross-model-review/references/report-format.md +22 -0
  131. package/dist/templates/common/bundled-skills/atw-diagnosing-bugs/SKILL.md +13 -2
  132. package/dist/templates/common/bundled-skills/atw-domain-modeling/SKILL.md +2 -2
  133. package/dist/templates/common/bundled-skills/atw-handoff/SKILL.md +1 -1
  134. package/dist/templates/common/bundled-skills/atw-implement/SKILL.md +37 -1
  135. package/dist/templates/common/bundled-skills/atw-implement-all/SKILL.md +219 -24
  136. package/dist/templates/common/bundled-skills/atw-implement-spec/SKILL.md +237 -18
  137. package/dist/templates/common/bundled-skills/atw-improve-codebase-architecture/SKILL.md +2 -2
  138. package/dist/templates/common/bundled-skills/atw-init-repo/SKILL.md +1 -1
  139. package/dist/templates/common/bundled-skills/atw-init-repo/references/issue-tracker-atw-local.md +7 -1
  140. package/dist/templates/common/bundled-skills/atw-init-repo/references/issue-tracker-atw.md +7 -1
  141. package/dist/templates/common/bundled-skills/atw-map/SKILL.md +1 -1
  142. package/dist/templates/common/bundled-skills/atw-meta/SKILL.md +1 -1
  143. package/dist/templates/common/bundled-skills/atw-meta/references/customize-local/add-project-local-conventions.md +4 -3
  144. package/dist/templates/common/bundled-skills/atw-meta/references/customize-local/change-skills-or-commands.md +21 -16
  145. package/dist/templates/common/bundled-skills/atw-meta/references/customize-local/change-task-lifecycle.md +1 -1
  146. package/dist/templates/common/bundled-skills/atw-meta/references/customize-local/overview.md +1 -0
  147. package/dist/templates/common/bundled-skills/atw-meta/references/local-architecture/task-system.md +2 -1
  148. package/dist/templates/common/bundled-skills/atw-meta/references/platform-files/hooks-and-settings.md +1 -0
  149. package/dist/templates/common/bundled-skills/atw-noob-mode/SKILL.md +1 -1
  150. package/dist/templates/common/bundled-skills/atw-performance/SKILL.md +134 -0
  151. package/dist/templates/common/bundled-skills/atw-performance/agents/openai.yaml +3 -0
  152. package/dist/templates/common/bundled-skills/atw-performance/references/optimization-patterns.md +250 -0
  153. package/dist/templates/common/bundled-skills/atw-performance/references/performance-checklist.md +238 -0
  154. package/dist/templates/common/bundled-skills/atw-performance/references/performance-optimization.md +264 -0
  155. package/dist/templates/common/bundled-skills/atw-prototype/SKILL.md +3 -3
  156. package/dist/templates/common/bundled-skills/atw-prototype/{LOGIC.md → logic.md} +1 -1
  157. package/dist/templates/common/bundled-skills/atw-prototype/references/design-direction.md +1 -1
  158. package/dist/templates/common/bundled-skills/atw-prototype/references/style-explorer.md +1 -1
  159. package/dist/templates/common/bundled-skills/atw-prototype/{UI.md → ui.md} +4 -4
  160. package/dist/templates/common/bundled-skills/atw-reflect/SKILL.md +77 -0
  161. package/dist/templates/common/bundled-skills/atw-reflect/agents/openai.yaml +5 -0
  162. package/dist/templates/common/bundled-skills/atw-reflect/references/divergent-reviewer.md +38 -0
  163. package/dist/templates/common/bundled-skills/atw-reflect/references/judgment-reviewer.md +37 -0
  164. package/dist/templates/common/bundled-skills/atw-reflect/references/synthesizer.md +63 -0
  165. package/dist/templates/common/bundled-skills/atw-reflect/references/tooling-reviewer.md +48 -0
  166. package/dist/templates/common/bundled-skills/atw-security/SKILL.md +248 -0
  167. package/dist/templates/common/bundled-skills/atw-security/agents/openai.yaml +3 -0
  168. package/dist/templates/common/bundled-skills/atw-security/references/hardening-patterns.md +324 -0
  169. package/dist/templates/common/bundled-skills/atw-security/references/security-checklist.md +248 -0
  170. package/dist/templates/common/bundled-skills/atw-session-insight/SKILL.md +1 -1
  171. package/dist/templates/common/bundled-skills/atw-session-insight/references/cli-quick-reference.md +11 -2
  172. package/dist/templates/common/bundled-skills/atw-spec/SKILL.md +1 -1
  173. package/dist/templates/common/bundled-skills/atw-tdd/SKILL.md +25 -0
  174. package/dist/templates/common/bundled-skills/atw-tickets/SKILL.md +1 -1
  175. package/dist/templates/common/bundled-skills/atw-triage/SKILL.md +5 -5
  176. package/dist/templates/common/bundled-skills/atw-ui/SKILL.md +54 -1
  177. package/dist/templates/common/bundled-skills/atw-ui/references/accessibility-checklist.md +160 -0
  178. package/dist/templates/common/bundled-skills/atw-verify-skill/SKILL.md +58 -0
  179. package/dist/templates/common/bundled-skills/atw-verify-skill/agents/openai.yaml +5 -0
  180. package/dist/templates/common/bundled-skills/atw-verify-skill/references/feature-map-example/README.md +47 -0
  181. package/dist/templates/common/bundled-skills/atw-verify-skill/references/feature-map-example/create-note.md +39 -0
  182. package/dist/templates/common/bundled-skills/atw-verify-skill/references/feature-map-example/search.md +45 -0
  183. package/dist/templates/common/bundled-skills/atw-verify-skill/references/generate.md +107 -0
  184. package/dist/templates/common/bundled-skills/atw-verify-skill/references/health-check.md +100 -0
  185. package/dist/templates/common/bundled-skills/atw-verify-skill/references/verify-skill-template.md +99 -0
  186. package/dist/templates/common/bundled-skills/atw-what-happened/SKILL.md +49 -0
  187. package/dist/templates/common/bundled-skills/atw-what-happened/agents/openai.yaml +5 -0
  188. package/dist/templates/common/commands/finish-work.md +10 -1
  189. package/dist/templates/common/commands/start.md +3 -1
  190. package/dist/templates/common/skills/update-spec.md +40 -4
  191. package/dist/templates/copilot/hooks/session-start.py +2 -2
  192. package/dist/templates/dsh/DSH.md +2 -2
  193. package/dist/templates/markdown/spec/backend/quality-guidelines.md.txt +10 -0
  194. package/dist/templates/markdown/spec/frontend/quality-guidelines.md.txt +10 -0
  195. package/dist/templates/omp/config.yml +15 -0
  196. package/dist/templates/omp/extensions/atw/index.ts.txt +135 -18
  197. package/dist/templates/omp/index.d.ts +1 -0
  198. package/dist/templates/omp/index.d.ts.map +1 -1
  199. package/dist/templates/omp/index.js +3 -0
  200. package/dist/templates/omp/index.js.map +1 -1
  201. package/dist/templates/opencode/lib/session-utils.js +4 -2
  202. package/dist/templates/opencode/plugins/inject-workflow-state.js +5 -1
  203. package/dist/templates/pi/extensions/atw/index.ts.txt +80 -7
  204. package/dist/templates/shared-hooks/index.d.ts +8 -1
  205. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  206. package/dist/templates/shared-hooks/index.js +8 -0
  207. package/dist/templates/shared-hooks/index.js.map +1 -1
  208. package/dist/templates/shared-hooks/inject-workflow-state.py +131 -14
  209. package/dist/templates/shared-hooks/session-start.py +4 -2
  210. package/dist/templates/shared-hooks/show-notice.py +135 -0
  211. package/dist/templates/snow/SNOW.md +1 -1
  212. package/dist/utils/initialized.d.ts +9 -0
  213. package/dist/utils/initialized.d.ts.map +1 -0
  214. package/dist/utils/initialized.js +24 -0
  215. package/dist/utils/initialized.js.map +1 -0
  216. package/dist/utils/managed-removal.d.ts +2 -0
  217. package/dist/utils/managed-removal.d.ts.map +1 -1
  218. package/dist/utils/managed-removal.js +23 -3
  219. package/dist/utils/managed-removal.js.map +1 -1
  220. package/dist/utils/optional-skills.d.ts +2 -0
  221. package/dist/utils/optional-skills.d.ts.map +1 -1
  222. package/dist/utils/optional-skills.js +1 -1
  223. package/dist/utils/optional-skills.js.map +1 -1
  224. package/dist/utils/template-hash.d.ts.map +1 -1
  225. package/dist/utils/template-hash.js +3 -0
  226. package/dist/utils/template-hash.js.map +1 -1
  227. package/dist/utils/uninstall-scrubbers.d.ts +10 -0
  228. package/dist/utils/uninstall-scrubbers.d.ts.map +1 -1
  229. package/dist/utils/uninstall-scrubbers.js +50 -0
  230. package/dist/utils/uninstall-scrubbers.js.map +1 -1
  231. package/dist/utils/verify-entries.d.ts +51 -0
  232. package/dist/utils/verify-entries.d.ts.map +1 -0
  233. package/dist/utils/verify-entries.js +399 -0
  234. package/dist/utils/verify-entries.js.map +1 -0
  235. package/package.json +2 -2
  236. /package/dist/templates/common/bundled-skills/atw-codebase-design/{DEEPENING.md → deepening.md} +0 -0
  237. /package/dist/templates/common/bundled-skills/atw-domain-modeling/{ADR-FORMAT.md → adr-format.md} +0 -0
  238. /package/dist/templates/common/bundled-skills/atw-domain-modeling/{GLOSSARY-FORMAT.md → glossary-format.md} +0 -0
  239. /package/dist/templates/common/bundled-skills/atw-improve-codebase-architecture/{HTML-REPORT.md → html-report.md} +0 -0
  240. /package/dist/templates/common/bundled-skills/atw-triage/{AGENT-BRIEF.md → agent-brief.md} +0 -0
  241. /package/dist/templates/common/bundled-skills/atw-triage/{OUT-OF-SCOPE.md → out-of-scope.md} +0 -0
@@ -0,0 +1,2002 @@
1
+ #!/usr/bin/env python3
2
+ """决定日志脚本 —— 安装到 .atw/scripts/decision_log.py。
3
+
4
+ decision_log.py start [--lang zh|en]
5
+ 写这次运行的 start 行:证据格是对话编号,结果格是它前面、不是这次对话写的
6
+ 那几行的时间范围(第一次写「这份日志从这里开始」)。写之前打印最后几行,
7
+ 以及有没有别的对话在这次对话写过之后插写。同一个对话已经是最后一段时不重复写。
8
+ 日志还没有时按 --lang 建标题与表头,之后沿用;中英两套表头都认
9
+ decision_log.py add --phase <阶段> --decision <决定> --why <理由>
10
+ --evidence <证据> --result <结果> [--mark needs-you|unsure]
11
+ 记一行;--mark 在结果格开头写「待你确认:」或「拿不准:」(英文日志写
12
+ `needs-you:`、`unsure:`)。--result 开头连着的同一种标记(中英哪套都认,可以混排)
13
+ 全部去掉、只留一个日志写法的;以另一种标记开头时照样加在前面、原文不动,这一行按
14
+ --mark 算。supersede、ack 同样
15
+ decision_log.py supersede --time <时间> <同 add 的栏>
16
+ 追加顶替行:决定格以「顶替 <时间> 那一行:」(英文 `supersedes <时间>:`)开头。
17
+ 顶替之后日志与长期指令表新对不上时照样记下,在标准错误提醒怎么写
18
+ decision_log.py ack --time <时间> [--time <时间> ...] <同 add 的栏>
19
+ 追加认可行:决定格以「认可 <时间> 那一行:」(几行时「那几行」;英文
20
+ `acknowledges <时间>, <时间>:`)开头
21
+ decision_log.py run [--json]
22
+ 只读,取这次运行那一段:这次对话从它第一条 start 行起写的行(别的对话插写的
23
+ 不算),带对话编号与起止时间,供日志自查与日志复查用。查不到这次对话的编号时
24
+ 不猜:只在最后一条 start 行的证据格也写着查不到时取它起的那一段(那段正是没有
25
+ 编号的对话写的),并标明这样取;最后一条 start 行带着编号就退出 2。--json 给
26
+ {session, picked_by, start, end, rows, evidence, transcript}:session 查不到为 null,picked_by 为
27
+ `session`(按编号取)或 `last-segment-without-session`(按没有编号的最后一段取)。
28
+ 还按行列出这次运行各行的证据指针,供日志自查第 3 步照着逐个打开(start 行与自查行不列):
29
+ evidence 是 [{time, phase, pointers: [{text, checkable}]}],拆法与 checkable 见下面自查行一段。
30
+ 另给日志自查读聊天记录的那条命令(`atw mem extract --context <编号> --tools --after <起>`,
31
+ 填好)与它此刻输出的总行数(参考数:自查行「读了聊天记录 X/N 行」的 N 照 AI 自己那次读取的
32
+ 输出写):transcript 是 {command, lines, problem}。编号查不到、手动设置的原样值、atw mem 读不了
33
+ 的平台、--tools 读法没验证过的平台(只数 Claude Code、Codex、pi、omp)不调 atw,command 为
34
+ null(文本「聊天记录读不了」);atw 不在 PATH 上、起不来、超时(ATW_DECISION_LOG_MEM_TIMEOUT_S,
35
+ 缺省 60 秒)、退出非零(退出 2 且报 no session matches 才算编号对不上)时 lines 为 null(文本
36
+ 「聊天记录数不出行数」);两种都照读不了聊天记录处理,problem 写原因,退出码仍是 0
37
+ decision_log.py unsure [--json]
38
+ 只读,列出整份日志里的「拿不准」行(不分哪次对话),已被顶替的不算、被认可的照样列;
39
+ 供验收与用户亲自要求的代码审查结尾判断要不要提跨模型复查。没有时说没有、
40
+ 退出 0。--json 给 {rows},每行六栏同 run
41
+ decision_log.py wait --title <标题> --ask <要你做什么> --why <为什么卡在你这儿>
42
+ --options <选项> --default <AI 先按哪个做了> --category <类别>
43
+ [--if-ignored <不处理会怎样>] [--prereq <要先做什么>]
44
+ [--irreversible <哪一步撤不回>] [--placeholder <文件:行> ...]
45
+ [--command <回来后可输入的命令> ...] [--ticket <在等它的工单> ...]
46
+ --phase <阶段> --evidence <证据>
47
+ 记进「等你处理」一条(编号接着往下),同一条命令里先写对应的日志行:决定格是标题、
48
+ 理由格是 --why、结果格「进「等你处理」第 N 条」(英文 `added to waiting-on-you as
49
+ item N`),不加标记;日志写不成(日志还没有等)清单也不写。清单那一步没写成时退出 2,日志里
50
+ 多出的登记行由 pending 对账报出,顶替它再重新 wait 就对上。类别只收 account-key-paid
51
+ (外部账号·密钥·付费)、design-choice、manual-test、legal、your-step(留给你的一步)、
52
+ unresolved-review、skipped-ticket、log-review-failed、other;没给的三项写「不知道」。
53
+ --command 是 skill 命令(`/skill:atw-…`、`/atw-…`、`$atw-…`)时照宿主改成当前工具的写法
54
+ (认宿主同 cross_model_review.py;pi、omp 是 `/skill:`),改了在标准错误里说;认不出宿主时原样
55
+ decision_log.py resolve --item <N> --reply <答复> --phase <阶段> --evidence <证据>
56
+ [--result <结果>]
57
+ 勾掉第 N 条、在它末尾写答复,同时追加日志行(决定格「勾掉「等你处理」第 N 条:标题」、
58
+ 理由格是答复、结果格缺省「已处理」);条目不删。已勾掉或没有这一条就退出 2。先写日志再勾清单:
59
+ 清单那一步没写成时这一条还开着,退出 2,pending 对账报出,再跑一次同一条命令就对上——日志里已有
60
+ 没被顶替的勾掉第 N 条的行时只勾清单、不写第二行。wait 与 resolve 都先写日志,中途断掉只会留下
61
+ 「日志里有、清单里对不上」这一种
62
+ decision_log.py pending [--json]
63
+ 只读,供汇报与验收用:清单里还没处理的条目,以及整份日志里还没被认可、也没被顶替的
64
+ 「待你确认」行与「拿不准」行(口径与 unsure 不同:unsure 只排除被顶替的)。日志与
65
+ 清单都没有时各项为空、退出 0。还列出任务目录 `log-review/` 里日志复查报告中还没处理的
66
+ 「要改」:报告里「要改」/「Must fix」标题下每条写成 `### N. 标题`,处理过的由
67
+ `cross_model_review.py report --append` 在报告末尾追加「已处理」/「Handled」一节、
68
+ 一行一条 `- 要改 N:答复`(`- Must fix N: reply`)。还对账日志与清单的两个方向,对不上的逐条
69
+ 列出、按没处理算:登记按条目的「对应日志行」对上结果格是登记写法的行,勾掉按编号对上决定格是
70
+ 勾掉写法的行,被顶替的行不算(日志里多出的登记行顶替掉就不再报)。--json 给
71
+ {waiting, needs_you, unsure, log_review_must_fix, mismatches}:waiting 每条
72
+ {number, done, title, ask, why, options, default, if_ignored, prereq, irreversible,
73
+ placeholders, commands, tickets, category, log_time, reply}(category 是上面的英文
74
+ 类别名),needs_you 与 unsure 每行六栏同 run,log_review_must_fix 每条
75
+ {report(任务目录相对路径), number, title},mismatches 每处 {kind, number, title, log_time},kind 是
76
+ log-only-added(日志说登记了、清单里没有对应这一行的条目)、list-only-added(条目在日志里没有
77
+ 对应的登记行)、log-only-resolved(日志说勾掉了、清单里没有勾上的这一条)、list-only-resolved
78
+ (条目勾上了、日志里没有勾掉它的行),还有长期指令表的两种:log-only-standing(日志说进了长期指令
79
+ 第 N 条、表里没有第 N 条)、list-only-standing(表里第 N 条在日志里没有对应的行),title 是那条指令;
80
+ 顶替行结果格没写编号时承接被顶替那一行说的、表里确有的第 N 条;
81
+ log_time 在 log-only 三种是那一行的时间、list-only-added 是条目写的对应日志行(没写为 null)、
82
+ list-only-resolved 与 list-only-standing 为 null。文本与 --report 第一块同样逐处列出、各带修法;小白模式开着时多一项 terms:前四项各一句大白话
83
+ (键同上,措辞跟着日志的语言),供验收汇报在这些说法第一次出现时附上;关着时没有这一项
84
+ decision_log.py pending --report
85
+ 只读,给汇报底稿(prd L 节,用户说「汇报一下」时照着贴):四块按顺序——等你处理(没处理的
86
+ 条目:标题、要你做什么、AI 先按哪个做了、回来后输入什么;日志与清单对不上的逐处列出、按没处理算;
87
+ 加 `ready-for-human` 还没做的工单)、日志复查挑出的问题(log-review/ 里的报告与还没处理的「要改」)、待你确认的条目
88
+ (时间、决定、理由)、每张工单的状态(每张一行:编号与标题、做完 / 进行中 / 跳过(「等你处理」
89
+ 第几条,按 `tickets.py skip` 写进 `## Comments` 的日志行时间对条目,对不上再找 `--ticket` 写着
90
+ 它的跳过条目)/ 等人做 / 没动,再加合计);措辞全跟着日志的语言(没有日志时跟着清单,都没有
91
+ 用中文),任务目录里没有的(提交号、审查结论、分支……)标成「(补:…)」留给 AI 补。
92
+ 小白模式开着时前三块标题下各先附一句大白话(同 --json 的 terms),第三块再加「拿不准」那句;关着时不附。
93
+ 没有当前任务时用 --task-dir 指定。与 --json 不能同时给
94
+ decision_log.py standing --order <指令> --why <理由> --phase <阶段> --evidence <证据> [--lang zh|en]
95
+ 追加一条长期指令(absorb-gaps-round-2 工单 07):同一条命令先记一行日志(决定格是指令、
96
+ 结果格「进长期指令第 N 条」/ `added to standing orders as item N`、不加标记),再往长期指令表
97
+ 末尾加一行;日志还没有时照 start 先开日志(要给 --lang);日志与表对不上时不追加、退出 2 并给出修法。
98
+ 只由主对话跑
99
+
100
+ 日志是任务目录里的 `decision-log.md`:一行标题加一张六栏表(时间、阶段、决定、
101
+ 理由、证据、结果),只往后加,旧行任何时候都不改不删。只由主对话写。
102
+ 时间是本地时间带时区、到秒,同一份日志里严格递增(同一秒再写顺延一秒);一行由
103
+ 它的时间唯一指称,顶替、认可指向的时间必须是日志里已有的行。格子只占一行:换行
104
+ 合成空格、竖线转义、去首尾空白。
105
+
106
+ 「等你处理」清单是同一目录的 `waiting-on-you.md`:一行标题(「# 等你处理」/
107
+ `# Waiting on you`)加一段说明(怎么答复:拍板的、动手的都在对话里告诉 AI,不用一直跑),每条是
108
+ `- [ ] **N. 标题**` 接着每个字段一行 ` - 字段名:值`(英文 ` - Field: value`),占位与命令每个值一行、
109
+ 包在反引号里;命令前注明在哪儿输:skill 命令是「在和 AI 的对话里输入」,其余是「自己在终端里运行」
110
+ (英文 `type in the chat with the AI` / `run yourself in a terminal`),读回时去掉。
111
+ 清单还没有时跟着日志的语言建,之后沿用;中英两套都认,标题认不出就退出 2。
112
+
113
+ 长期指令表是同一目录的 `standing-orders.md`:只有一条条 `N. 指令`、不带标题(整份原样贴进派活单,
114
+ 不带进标题和日志的语言),编号从 1 起递增、一行一条,只往后加,旧行不改不删。只由主对话经 standing
115
+ 写;连做工单时每次派活整份原样贴进派活单。写入顺序同 wait:先写日志再写表,表没写成时报错里给出修法
116
+ (顶替那一行再 standing)。pending 按编号对账两个方向(日志说进了第 N 条、表里没有;表里第 N 条、
117
+ 日志里没有对应的行,被顶替的行不算),对不上时 standing 不追加、退出 2 并给出修法,编号不会被另一条占用。
118
+
119
+ 阶段格跟着用户的语言:中文日志里写纯英文的阶段格(有英文字母、没有汉字)时,add / supersede /
120
+ ack / wait / resolve / standing 在标准错误里提醒,照样记下。
121
+
122
+ 自查行(决定格以「日志自查」/ `log self-check` 开头)的结果格写了「逐行」「全部对照」一类的笼统字样,
123
+ 或没写「自查时打开:」「没打开:」(`opened during the check:`、`not opened:`)时,add / supersede / ack
124
+ 在标准错误里提醒,照样记下;结果格写着没有对照聊天记录的也一样。
125
+ 自查行写了这两个标签之一时,还对照这次运行在它之前写的各行的证据指针(同 run 列出的):认得出的指针
126
+ 在结果格里从第一个标签起到末尾都没出现的,逐个提醒(同一个指针几行都有时一条点出那几行),照样记下。
127
+ 证据格先按分号(;;)拆;顿号、逗号(、,, )只在拆出的每段都认得出时再拆,「第 1、2 条」整段不拆。
128
+ 认得出(checkable)的是三种:提交号(7–40 位小写十六进制、字母数字都有,前后紧挨 `-` 的是 UUID 的一段、
129
+ 不算),对照时互为前缀就算出现;文件——扩展名得是常见的代码、文档、配置、补丁扩展名(FILE_EXTS;`e.g`、
130
+ `v2.x`、`done.Next`、域名不算),不带目录时主名不能是一个首字母大写的词(`Node.js` 这类框架名不算;
131
+ 代价是 `Main.java` 这类不带目录的写法认不出、少提醒),写出路径或文件名就算(行号不管怎么写);
132
+ 「等你处理第 N 条」/ `waiting-on-you`,写到「等你处理」/ `waiting-on-you` 就算。一段里有一个认得出的指针出现就算这段出现了。认不出的段(审查报告
133
+ 第几条、测试命令等)不提醒:拆不准时宁可少提醒,不把整段当漏列。
134
+ 自查行的「没打开:」那一段(从标签起,到它后面的「自查时打开:」为止)按分号(;;)拆成一项项,注明了已顶替的项
135
+ (写到「顶替」/ `supersed`)不看;其余项里写了「已当场复核」一类说法(「当场复核/核实/核对/核过/验证/重跑」、
136
+ 「已复核/核实/核对/核过」、`on the spot`、`already verified/checked/rechecked`),或列了子代理的报告(「实现者报告」
137
+ 「审查报告」「Spec 报告」这类带角色的报告、「来自报告」、`implementer's report`、`review report`、`per the report`)
138
+ 而这次运行在自查行之前写的行里有证据格带报告指针(认法同上)、自己结果格没写「来自报告」/ `per the report`、也没被
139
+ 结果格这样写的顶替行顶替的,再提醒一句:只来自报告、当场核不了的结论要追加「来自报告」顶替行,列进「没打开:」或
140
+ 写「已当场复核」都不算(点不出是哪一行),照样记下;同时有漏列提醒时,漏列那段收尾不再重复「来自报告」那句。
141
+ 所以照规矩做了仍会提醒的只有两种:「没打开:」里写了核过了的说法而那一项没注明已顶替;报告指针所在的行都顶替了,
142
+ 但又有别的行带报告指针没顶替(报告不分是哪一份)。「已当场复核」只在「没打开:」那段里认:写在「自查时打开:」里、
143
+ 带着核它的命令的当场复核正是该有的写法;只在「没打开:」里说核过了,等于没给命令,也代替不了顶替行。
144
+
145
+ 每次运行写的第一行必须是 start 行:add / supersede / ack / wait / resolve / standing 发现最后一段
146
+ 不是这次对话的,就照 start 先补一行 start 行,同样先打印最后几行与有没有别的对话插写:
147
+ 这次对话还没写过 start 时接着写这一行;这次对话写过、别的对话在这之后插写过时只补 start 行、
148
+ 这一行不写、退出 2,读完再跑一次同一条命令。这一行本身不合规(参数不对、指向的时间不存在等)
149
+ 就 start 行也不补。没东西要记的对话因此不留下孤零零的 start 行。日志还没有时不补,要先跑
150
+ start --lang。查不到对话编号时,最后一段的 start 行记的是某个具体对话就先补一行证据格写明查不到的
151
+ start 行再写(免得这一行算到那个对话名下);最后一段的 start 行已经写着查不到、或日志里还没有 start
152
+ 行时不补。
153
+
154
+ 所有命令都接受 `--task-dir <仓库相对的任务目录>`(缺省问 `task.py current`)与
155
+ `--session <对话编号>`(缺省取 ATW 的对话标识:`ATW_CONTEXT_ID` 或平台给的会话编号)。
156
+
157
+ 写日志、清单或长期指令表的命令(start、add、supersede、ack、wait、resolve、standing)从读文件、检查、补 start 行、
158
+ 算时间到写完,整段拿着这个任务的锁:`.atw/.runtime/decision-log-locks/<任务目录名>.lock`,
159
+ 独占创建、写完删掉。拿不到时每隔一会儿重试,最多等 ATW_DECISION_LOG_LOCK_TIMEOUT_S 秒(缺省 10),
160
+ 超时报错、文件不动。锁里记着持锁的进程号:那个进程已不在,就当作异常退出留下的、接管;
161
+ 进程还在时锁再旧也不接管。Windows 上不查进程、或进程号认不出时,锁比 LOCK_STALE_S 秒还旧
162
+ 才接管。文件都先写同目录临时文件再整体换上,写到一半被打断时文件是写前或写后的完整样子。
163
+
164
+ 退出码:0 成功;2 没写成或没取到(没有任务、日志表头或清单标题认不出、参数不对、日志里
165
+ 没有这次对话的 start 行、写行命令遇到别的对话插写过(只补了 start 行)、清单里没有那一条或已勾掉、
166
+ 等锁超时)。
167
+ """
168
+ from __future__ import annotations
169
+
170
+ import argparse
171
+ import json
172
+ import os
173
+ import re
174
+ import shutil
175
+ import subprocess
176
+ import sys
177
+ import time
178
+ import uuid
179
+ from contextlib import contextmanager, nullcontext
180
+ from collections.abc import Iterator
181
+ from dataclasses import dataclass
182
+ from datetime import datetime, timedelta
183
+ from pathlib import Path
184
+
185
+ from common import configure_encoding
186
+ from common.active_task import DIR_RUNTIME, resolve_context_key
187
+ from common.io import write_text_atomic
188
+ from common.noob_mode import is_noob_mode_on
189
+ from common.paths import DIR_SCRIPTS, DIR_WORKFLOW, get_repo_root
190
+
191
+ EXIT_OK = 0
192
+ EXIT_ERROR = 2
193
+
194
+ LOG_NAME = "decision-log.md"
195
+ START_PHASE = "start"
196
+ # start 时给出的最后几行
197
+ TAIL_ROWS = 5
198
+ # 顶替行决定格的开头(写法见 LANGS 的 supersedes),取出被顶替的时间
199
+ SUPERSEDES_RE = re.compile(r"^顶替 (\S+) 那一行:|^supersedes (\S+): ")
200
+ # 认可行决定格的开头(写法见 LANGS 的 ack_one / ack_many),取出被认可的那几个时间
201
+ ACKS_RE = re.compile(r"^认可 (\S+) 那[一几]行:|^acknowledges (.+?): ")
202
+ ACK_TIME_SEP_RE = re.compile(r"、|, ")
203
+ # 对账认的两种行(工单 04):登记行的结果格、勾掉行的决定格(写法见 LANGS 的 wait_added / wait_resolved)
204
+ WAIT_ADDED_RE = re.compile(r"^进「等你处理」第 (\d+) 条$|^added to waiting-on-you as item (\d+)$")
205
+ WAIT_RESOLVED_RE = re.compile(r"^勾掉「等你处理」第 (\d+) 条:(.*)$|^checked off waiting-on-you item (\d+): (.*)$")
206
+ # 对账认的长期指令行(工单 07):追加那一行的结果格(写法见 LANGS 的 standing_added)
207
+ STANDING_ADDED_RE = re.compile(r"^进长期指令第 (\d+) 条$|^added to standing orders as item (\d+)$")
208
+
209
+
210
+ @dataclass(frozen=True)
211
+ class Lang:
212
+ title: str
213
+ header: tuple[str, ...]
214
+ start_decision: str
215
+ start_why: str
216
+ log_begins: str
217
+ prior_range: str # {a} {b}
218
+ no_session: str
219
+ no_transcript: str # 读不了聊天记录时自查行结果格的开头
220
+ marks: dict[str, str]
221
+ supersedes: str # {t}
222
+ ack_one: str # {t}
223
+ ack_many: str # {ts}
224
+ time_sep: str
225
+ wait_added: str # {n}:记进清单那一行的结果格
226
+ wait_resolved: str # {n} {title}:勾掉那一行的决定格
227
+ wait_resolved_result: str
228
+ standing_added: str # {n}:追加长期指令那一行的结果格
229
+
230
+
231
+ LANGS: dict[str, Lang] = {
232
+ "zh": Lang(
233
+ title="# 决定日志",
234
+ header=("时间", "阶段", "决定", "理由", "证据", "结果"),
235
+ start_decision="这次运行接着记",
236
+ start_why="标出哪段是这次运行写的",
237
+ log_begins="这份日志从这里开始",
238
+ prior_range="前面 {a} 至 {b} 不是这次运行写的",
239
+ no_session="对话编号查不到",
240
+ no_transcript="没有对照聊天记录:<原因>",
241
+ marks={"needs-you": "待你确认:", "unsure": "拿不准:"},
242
+ supersedes="顶替 {t} 那一行:",
243
+ ack_one="认可 {t} 那一行:",
244
+ ack_many="认可 {ts} 那几行:",
245
+ time_sep="、",
246
+ wait_added="进「等你处理」第 {n} 条",
247
+ wait_resolved="勾掉「等你处理」第 {n} 条:{title}",
248
+ wait_resolved_result="已处理",
249
+ standing_added="进长期指令第 {n} 条",
250
+ ),
251
+ "en": Lang(
252
+ title="# Decision log",
253
+ header=("time", "phase", "decision", "why", "evidence", "result"),
254
+ start_decision="this run continues the log",
255
+ start_why="marks which rows this run wrote",
256
+ log_begins="the log starts here",
257
+ prior_range="rows {a} to {b} were not written by this run",
258
+ no_session="session id unavailable",
259
+ no_transcript="not checked against the transcript: <reason>",
260
+ marks={"needs-you": "needs-you: ", "unsure": "unsure: "},
261
+ supersedes="supersedes {t}: ",
262
+ ack_one="acknowledges {t}: ",
263
+ ack_many="acknowledges {ts}: ",
264
+ time_sep=", ",
265
+ wait_added="added to waiting-on-you as item {n}",
266
+ wait_resolved="checked off waiting-on-you item {n}: {title}",
267
+ wait_resolved_result="handled",
268
+ standing_added="added to standing orders as item {n}",
269
+ ),
270
+ }
271
+
272
+
273
+ WAIT_NAME = "waiting-on-you.md"
274
+ # 长期指令表(absorb-gaps-round-2 工单 07):只有一行行「N. 指令」,不带标题,整份原样贴进派活单
275
+ STANDING_NAME = "standing-orders.md"
276
+ STANDING_ITEM_RE = re.compile(r"^(\d+)\. (.*)$")
277
+ # 清单每条的字段,按写出的顺序;reply 只在勾掉时写
278
+ WAIT_FIELDS = (
279
+ "ask", "why", "options", "default", "if_ignored", "prereq", "irreversible",
280
+ "placeholders", "commands", "tickets", "category", "log_time", "reply",
281
+ )
282
+ # 照原样的多值字段:每个值一行、包在反引号里
283
+ CODE_LIST_FIELDS = ("placeholders", "commands")
284
+ # E 节的类别,按 E 节的顺序
285
+ CATEGORIES = (
286
+ "account-key-paid", "design-choice", "manual-test", "legal", "your-step",
287
+ "unresolved-review", "skipped-ticket", "log-review-failed", "other",
288
+ )
289
+ PLACEHOLDER_RE = re.compile(r"^\S.*:\d+$")
290
+ # 回来后输入的 skill 命令:三种工具的写法(`/skill:`、`/`、`$`)后接 atw-<名字>,再接空白或结尾
291
+ SKILL_INPUT_RE = re.compile(r"^(?:/skill:|/|\$)(atw-[a-z0-9-]+(?:\s.*)?)$")
292
+ # 阶段格跟着日志的语言(prd B 节):中文日志里有英文字母、没有一个汉字的算纯英文
293
+ HAN_RE = re.compile(r"[\u3400-\u9fff\uf900-\ufaff]")
294
+ LATIN_RE = re.compile(r"[A-Za-z]")
295
+ # 自查行(工单 28、29,prd J 节):结果格不写笼统字样,列出自查时打开了什么、没打开什么
296
+ SELF_CHECK_RE = re.compile(r"^(?:日志自查|log self-check)")
297
+ # 「逐条」「row by row」不在其中:自查第 3 步本来就逐条过日志行,说的是日志行、不是读了多少、打开了多少
298
+ THOROUGH_RE = re.compile(r"逐行|全部对照|全文|每行证据|各行证据|line by line|every line|in full|every row's evidence", re.I)
299
+ OPENED_RE = re.compile(r"自查时打开\s*[::]|opened during the check\s*:", re.I)
300
+ NOT_OPENED_RE = re.compile(r"没打开\s*[::]|not opened\s*:", re.I)
301
+ # 「没打开:」里的报告类指针与「已当场复核」一类说法(工单 32):只来自报告的结论要追加「来自报告」顶替行,
302
+ # 列进「没打开:」或说核过了都不算。报告只认带角色的(实现者、审查、复查、子代理、Spec、Standards)与
303
+ # 「来自报告」/ per the report;说法只认「当场复核」「已核实」一类,英文认 on the spot 与 already verified 一类
304
+ FROM_REPORT_RE = re.compile(
305
+ r"(?:实现者?|审查员?|复查员?|子代理|Spec|Standards)\s*(?:的\s*)?(?:审查\s*)?报告|来自报告"
306
+ r"|\b(?:implementer|implementation|review|reviewer|sub-?agent|spec|standards)(?:'s|s')?\s+(?:review\s+)?reports?\b"
307
+ r"|\b(?:per|from) the report\b", re.I)
308
+ CHECKED_CLAIM_RE = re.compile(
309
+ r"当场(?:复核|核实|核对|核过|验证|重跑)|已(?:经)?(?:复核|核实|核对|核过)"
310
+ r"|\bon the spot\b|\balready (?:verified|re-?checked|checked)\b", re.I)
311
+ # 行的结果格写明了结论来自报告;「没打开:」那一项注明了已顶替
312
+ FROM_REPORT_MARK_RE = re.compile(r"来自报告|\bper the report\b", re.I)
313
+ SUPERSEDED_NOTE_RE = re.compile(r"顶替|supersed", re.I)
314
+ # 证据格拆指针(工单 31):先按分号拆;顿号、逗号只在拆出的每段都认得出时再拆(「第 1、2 条」不拆)
315
+ POINTER_SEP_RE = re.compile(r"[;;]")
316
+ POINTER_LIST_SEP_RE = re.compile(r"、|,|, ")
317
+ # 认得出的三种指针:提交号(7–40 位十六进制、字母数字都有;前后紧挨 `-` 的是 UUID 的一段,不算)、
318
+ # 文件(扩展名在 FILE_EXTS 里)、「等你处理」第几条
319
+ SHA_RE = re.compile(r"(?<![0-9A-Za-z_\-])[0-9a-f]{7,40}(?![0-9A-Za-z_\-])")
320
+ PATH_RE = re.compile(r"(?<![A-Za-z0-9_.\-/])[A-Za-z0-9_.\-/]*[A-Za-z0-9_\-]\.([A-Za-z][A-Za-z0-9]*)(?![A-Za-z0-9_\-/])")
321
+ # 只认常见代码、文档、配置、补丁的扩展名:`e.g`、`v2.x`、`done.Next`、域名(`chinhae.cc`、`.com`)都不是文件
322
+ FILE_EXTS = frozenset((
323
+ "py pyi ts tsx mts cts js jsx mjs cjs json jsonl md mdx txt yaml yml toml ini cfg lock sh bash zsh "
324
+ "patch diff log csv tsv sql html htm css scss vue svelte go rs java kt swift rb php c h cpp hpp xml svg"
325
+ ).split())
326
+ # 不带目录、主名是一个首字母大写的词(`Node.js`、`Next.js`、`Vue.js`)的是框架名,不是文件
327
+ BARE_WORD_STEM_RE = re.compile(r"[A-Z][a-z0-9]*")
328
+ WAITING_ITEM_RE = re.compile(r"等你处理(?:清单)?\s*第|waiting-on-you", re.I)
329
+ # 自查行的清单里写到「等你处理」就算提到了第几条
330
+ WAITING_MENTION_RE = re.compile(r"等你处理|waiting-on-you", re.I)
331
+ # atw mem 的 --tools 读法验证过的四个平台(Claude Code、Codex、pi、omp),只给这几个数行数(prd P 节)
332
+ VERIFIED_TOOLS_PLATFORMS = ("claude", "codex", "pi", "omp")
333
+ # run 数聊天记录行数时等 atw mem extract 的秒数(同 cross_model_review 的 PROBE_TIMEOUT_S 写法,测试可缩短)
334
+ MEM_TIMEOUT_S = float(os.environ.get("ATW_DECISION_LOG_MEM_TIMEOUT_S") or 60)
335
+ # 写命令的锁(prd D 节):放在不进版本记录的 .atw/.runtime/ 下,一个任务一把
336
+ LOCK_DIR = "decision-log-locks"
337
+ WRITE_COMMANDS = ("start", "add", "supersede", "ack", "wait", "resolve", "standing")
338
+ # 拿不到锁时总共等的秒数(测试可缩短)与重试间隔
339
+ LOCK_TIMEOUT_S = float(os.environ.get("ATW_DECISION_LOG_LOCK_TIMEOUT_S") or 10)
340
+ LOCK_POLL_S = 0.05
341
+ # 认不出持锁进程(Windows、进程号没写上)时按年龄判断:锁里的活几毫秒就做完,比这还旧的锁当作异常退出留下的
342
+ LOCK_STALE_S = 10
343
+ ITEM_RE = re.compile(r"^- \[([ xX])\] \*\*(\d+)\. (.*)\*\*$")
344
+ SUB_VALUE_RE = re.compile(r"^ - (.*)$")
345
+
346
+
347
+ @dataclass(frozen=True)
348
+ class WaitLang:
349
+ title: str
350
+ intro: str
351
+ labels: dict[str, str]
352
+ # 「回来后可以输入」每条命令前注明在哪儿输:skill 命令在和 AI 的对话里输,其余自己在终端里运行
353
+ command_kinds: dict[str, str]
354
+ field_sep: str
355
+ unknown: str
356
+ none: str
357
+ list_sep: str
358
+ categories: dict[str, str]
359
+
360
+
361
+ WAIT_LANGS: dict[str, WaitLang] = {
362
+ "zh": WaitLang(
363
+ title="# 等你处理",
364
+ intro=(
365
+ "AI 替不了、要你来办的事。怎么答复:\n\n"
366
+ "- 要你拍板的:直接在对话里告诉 AI 你选哪个,例如「第 1 条按 B」。\n"
367
+ "- 要你动手的(填账号密钥、真机核对之类):做完在对话里告诉 AI,它核对过再勾掉。\n"
368
+ "- 不用一直跑下去,也不用自己改这份清单。答复完想让 AI 接着做剩下的,再输入标着「在和 AI 的对话里输入」"
369
+ "的那条命令;标着「自己在终端里运行」的,要你自己在终端里敲。\n\n"
370
+ "处理完的条目会勾上并写下你的答复,不删。"
371
+ ),
372
+ labels={
373
+ "ask": "要你做什么",
374
+ "why": "为什么卡在你这儿",
375
+ "options": "选项",
376
+ "default": "默认(AI 先按这个做了)",
377
+ "if_ignored": "不处理会怎样",
378
+ "prereq": "要先做什么",
379
+ "irreversible": "哪一步撤不回",
380
+ "placeholders": "占位在哪",
381
+ "commands": "回来后可以输入",
382
+ "tickets": "在等它的工单",
383
+ "category": "类别",
384
+ "log_time": "对应日志行",
385
+ "reply": "你的答复",
386
+ },
387
+ command_kinds={"chat": "在和 AI 的对话里输入 ", "terminal": "自己在终端里运行 "},
388
+ field_sep=":",
389
+ unknown="不知道",
390
+ none="无",
391
+ list_sep="、",
392
+ categories={
393
+ "account-key-paid": "外部账号·密钥·付费",
394
+ "design-choice": "设计或产品选择",
395
+ "manual-test": "手动测试",
396
+ "legal": "法律决定",
397
+ "your-step": "留给你的一步",
398
+ "unresolved-review": "没解决的审查问题",
399
+ "skipped-ticket": "跳过的工单",
400
+ "log-review-failed": "日志复查没做成",
401
+ "other": "其他",
402
+ },
403
+ ),
404
+ "en": WaitLang(
405
+ title="# Waiting on you",
406
+ intro=(
407
+ "Things the AI cannot do for you. How to answer:\n\n"
408
+ "- Something for you to decide: tell the AI in the chat which option you pick, e.g. \"item 1: go with B\".\n"
409
+ "- Something for you to do (an account key to fill in, a check on the real device): do it, then tell "
410
+ "the AI in the chat; it checks and then ticks it off.\n"
411
+ "- Nothing has to keep running, and you do not edit this list yourself. Once you have answered, if you "
412
+ "want the AI to carry on with the rest, type the command marked \"type in the chat with the AI\"; "
413
+ "one marked \"run yourself in a terminal\" is for you to type in a terminal.\n\n"
414
+ "Handled items get checked off with your reply and stay here."
415
+ ),
416
+ labels={
417
+ "ask": "What you need to do",
418
+ "why": "Why it is on you",
419
+ "options": "Options",
420
+ "default": "Default (what the AI went with)",
421
+ "if_ignored": "If you leave it",
422
+ "prereq": "What has to happen first",
423
+ "irreversible": "What cannot be undone",
424
+ "placeholders": "Placeholders",
425
+ "commands": "Commands to run when you are back",
426
+ "tickets": "Tickets waiting on it",
427
+ "category": "Category",
428
+ "log_time": "Log row",
429
+ "reply": "Your reply",
430
+ },
431
+ command_kinds={"chat": "type in the chat with the AI ", "terminal": "run yourself in a terminal "},
432
+ field_sep=": ",
433
+ unknown="unknown",
434
+ none="none",
435
+ list_sep=", ",
436
+ categories={
437
+ "account-key-paid": "external account · key · payment",
438
+ "design-choice": "design or product choice",
439
+ "manual-test": "manual test",
440
+ "legal": "legal decision",
441
+ "your-step": "a step left for you",
442
+ "unresolved-review": "unresolved review finding",
443
+ "skipped-ticket": "skipped ticket",
444
+ "log-review-failed": "log review not done",
445
+ "other": "other",
446
+ },
447
+ ),
448
+ }
449
+
450
+
451
+ class LogError(Exception):
452
+ """main 统一转成退出码 2。"""
453
+
454
+
455
+ @dataclass
456
+ class WaitItem:
457
+ number: int
458
+ done: bool
459
+ title: str
460
+ fields: dict[str, object]
461
+ start: int # 标题那一行的下标
462
+ end: int # 这一条最后一个非空行的下标 + 1
463
+
464
+
465
+ @dataclass
466
+ class Row:
467
+ time: datetime
468
+ raw_time: str
469
+ phase: str
470
+ decision: str
471
+ why: str
472
+ evidence: str
473
+ result: str
474
+
475
+
476
+ # ---------------------------------------------------------------------------
477
+ # 读与写
478
+ # ---------------------------------------------------------------------------
479
+
480
+ def split_cells(line: str) -> list[str]:
481
+ """一行表格拆成格子:只按没转义的竖线切。"""
482
+ body = line.strip()
483
+ if body.startswith("|"):
484
+ body = body[1:]
485
+ if body.endswith("|") and not body.endswith("\\|"):
486
+ body = body[:-1]
487
+ cells: list[str] = []
488
+ cur = ""
489
+ i = 0
490
+ while i < len(body):
491
+ ch = body[i]
492
+ if ch == "\\" and i + 1 < len(body) and body[i + 1] == "|":
493
+ cur += "\\|"
494
+ i += 2
495
+ continue
496
+ if ch == "|":
497
+ cells.append(cur.strip())
498
+ cur = ""
499
+ else:
500
+ cur += ch
501
+ i += 1
502
+ cells.append(cur.strip())
503
+ return cells
504
+
505
+
506
+ def one_line(text: str) -> str:
507
+ """换行合成空格、去首尾空白。"""
508
+ return " ".join(text.replace("\r\n", "\n").replace("\r", "\n").split("\n")).strip()
509
+
510
+
511
+ def cell(text: str) -> str:
512
+ """格子只占一行:换行合成空格、转义竖线、去首尾空白。"""
513
+ return one_line(text).replace("|", "\\|")
514
+
515
+
516
+ def format_row(cells: list[str]) -> str:
517
+ return "| " + " | ".join(cells) + " |"
518
+
519
+
520
+ def read_log(path: Path) -> tuple[str, list[Row]] | None:
521
+ """日志不存在返回 None;存在则返回 (语言, 数据行)。表头认不出就报错,不往坏文件里写。"""
522
+ if not path.is_file():
523
+ return None
524
+ lines = path.read_text(encoding="utf-8").splitlines()
525
+ lang: str | None = None
526
+ header_at = -1
527
+ for i, line in enumerate(lines):
528
+ if not line.strip().startswith("|"):
529
+ continue
530
+ cells = tuple(split_cells(line))
531
+ for code, spec in LANGS.items():
532
+ if cells == spec.header:
533
+ lang = code
534
+ header_at = i
535
+ break
536
+ if lang is None:
537
+ raise LogError(f"{path} 里认不出决定日志的表头(中文或英文六栏),不往里写")
538
+ rows: list[Row] = []
539
+ for line in lines[header_at + 2:]:
540
+ if not line.strip().startswith("|"):
541
+ continue
542
+ cells = split_cells(line)
543
+ if len(cells) != 6:
544
+ raise LogError(f"{path} 里有一行不是六栏:{line.strip()[:60]}")
545
+ try:
546
+ when = datetime.fromisoformat(cells[0])
547
+ except ValueError:
548
+ raise LogError(f"{path} 里有一行的时间认不出:{cells[0]}")
549
+ rows.append(Row(when, cells[0], *cells[1:]))
550
+ return lang, rows
551
+
552
+
553
+ def next_time(rows: list[Row]) -> str:
554
+ """本地时间带时区、到秒;不晚于上一行时取上一行加一秒,保证严格递增。"""
555
+ now = datetime.now().astimezone().replace(microsecond=0)
556
+ if rows:
557
+ floor = rows[-1].time + timedelta(seconds=1)
558
+ if now < floor:
559
+ now = floor.astimezone(now.tzinfo)
560
+ return now.isoformat(timespec="seconds")
561
+
562
+
563
+ def append_row(path: Path, rows: list[Row], cells: list[str]) -> str:
564
+ """往已有的日志追加一行,返回它的时间。"""
565
+ when = next_time(rows)
566
+ line = format_row([when] + [cell(c) for c in cells])
567
+ text = path.read_text(encoding="utf-8")
568
+ if not text.endswith("\n"):
569
+ text += "\n"
570
+ write_file(path, text + line + "\n")
571
+ return when
572
+
573
+
574
+ def write_file(path: Path, text: str) -> None:
575
+ """先写同目录临时文件再整体换上;没写成就报错,原文件不动。"""
576
+ if not write_text_atomic(path, text):
577
+ raise LogError(f"{path} 没写成(临时文件写不了或换不上),原文件没动")
578
+
579
+
580
+ def read_lock(path: Path) -> str | None:
581
+ """锁文件的内容;锁已不在返回 None,读不了返回空串(按认不出进程号处理)。"""
582
+ try:
583
+ return path.read_text(encoding="utf-8")
584
+ except FileNotFoundError:
585
+ return None
586
+ except OSError:
587
+ return ""
588
+
589
+
590
+ def holder_pid(seen: str) -> int | None:
591
+ """锁里记的持锁进程号;没写或认不出返回 None。"""
592
+ first = seen.split()[0] if seen.split() else ""
593
+ return int(first) if first.isdigit() and 0 < int(first) < 2 ** 31 else None
594
+
595
+
596
+ def pid_alive(pid: int) -> bool:
597
+ """进程还在返回 True;没权限给它发信号也说明它在。只在非 Windows 上用。"""
598
+ try:
599
+ os.kill(pid, 0)
600
+ except ProcessLookupError:
601
+ return False
602
+ except PermissionError:
603
+ return True
604
+ return True
605
+
606
+
607
+ def lock_is_stale(path: Path, seen: str) -> bool:
608
+ """持锁进程已不在,或认不出持锁进程时锁太旧:当作异常退出留下的。"""
609
+ try:
610
+ age = time.time() - path.stat().st_mtime
611
+ except FileNotFoundError:
612
+ return False
613
+ pid = holder_pid(seen)
614
+ # 认得出进程号时只看进程在不在:被挂起、休眠的持锁进程醒来还会接着写,不按年龄抢。
615
+ # Windows 上 os.kill(pid, 0) 会发 Ctrl-C,不拿它查进程,只看锁的新旧
616
+ if os.name != "nt" and pid is not None:
617
+ return not pid_alive(pid)
618
+ return age > LOCK_STALE_S
619
+
620
+
621
+ def take_over(path: Path, seen: str) -> None:
622
+ """挪走判定为死锁的那把锁;挪走的若已是别人刚拿到的新锁,放回去,放不回去就报错。
623
+
624
+ 不是绝对互斥:两个进程都把同一把死锁判为该接管时,先挪的拿到新锁,后挪的会挪走这把新锁再放回;
625
+ 放回之前若又有第三个进程建了锁,就放不回去——这时报错不写,但刚拿到锁的那个进程已失去锁文件,
626
+ 可能和第三个进程同时写。三方交错在几毫秒内才会发生,需求不要求绝对互斥。
627
+ """
628
+ aside = path.with_name(f"{path.name}.{os.getpid()}.stale")
629
+ try:
630
+ os.replace(path, aside)
631
+ except FileNotFoundError:
632
+ return
633
+ try:
634
+ if aside.read_text(encoding="utf-8") != seen:
635
+ try:
636
+ os.link(aside, path)
637
+ except OSError as e:
638
+ raise LogError(f"接管锁 {path} 时挪走了别的进程刚拿到的锁,放不回去({e}):"
639
+ "没写,稍后再跑同一条命令")
640
+ finally:
641
+ aside.unlink(missing_ok=True)
642
+
643
+
644
+ def create_lock(path: Path) -> int | None:
645
+ """独占创建锁文件,返回文件描述符;锁已在返回 None(Windows 上正被删的锁文件报 PermissionError,同样算在)。"""
646
+ try:
647
+ return os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
648
+ except (FileExistsError, PermissionError):
649
+ return None
650
+
651
+
652
+ @contextmanager
653
+ def log_lock(root: Path, task: Path) -> Iterator[None]:
654
+ """这个任务的决定日志与「等你处理」清单的写锁:独占创建锁文件,等不到就报错。"""
655
+ path = root / DIR_WORKFLOW / DIR_RUNTIME / LOCK_DIR / f"{task.name}.lock"
656
+ path.parent.mkdir(parents=True, exist_ok=True)
657
+ deadline = time.monotonic() + LOCK_TIMEOUT_S
658
+ while True:
659
+ fd = create_lock(path)
660
+ if fd is not None:
661
+ break
662
+ seen = read_lock(path)
663
+ if seen is not None and lock_is_stale(path, seen):
664
+ take_over(path, seen)
665
+ continue
666
+ # 锁不在却建不成(刚被释放,或锁目录没写权限)同样受等待上限约束、隔一会儿再试,不空转
667
+ if time.monotonic() >= deadline:
668
+ if seen is None:
669
+ raise LogError(f"建不了锁文件 {path}:等了 {LOCK_TIMEOUT_S:g} 秒,锁不在却一直建不成,"
670
+ "看看这个目录能不能写")
671
+ raise LogError(f"决定日志正被别的进程写:锁 {path} 等了 {LOCK_TIMEOUT_S:g} 秒还在。稍后再跑同一条命令;"
672
+ "确认没有别的进程在写时可以删掉这个锁文件")
673
+ time.sleep(LOCK_POLL_S)
674
+ token = f"{os.getpid()} {uuid.uuid4().hex}\n"
675
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
676
+ f.write(token)
677
+ try:
678
+ yield
679
+ finally:
680
+ # 只删自己的锁:这把锁被当作死锁接管过时不删别人的
681
+ if read_lock(path) == token:
682
+ path.unlink(missing_ok=True)
683
+
684
+
685
+ def create_log(path: Path, lang_code: str) -> None:
686
+ spec = LANGS[lang_code]
687
+ head = f"{spec.title}\n\n{format_row(list(spec.header))}\n{format_row(['---'] * 6)}\n"
688
+ write_file(path, head)
689
+
690
+
691
+ def read_waiting(path: Path) -> tuple[str, list[str], list[WaitItem]] | None:
692
+ """清单不存在返回 None;存在则返回 (语言, 各行, 条目)。标题认不出就报错,不往坏文件里写。"""
693
+ if not path.is_file():
694
+ return None
695
+ lines = path.read_text(encoding="utf-8").splitlines()
696
+ first = next((l.strip() for l in lines if l.strip()), "")
697
+ lang = next((code for code, spec in WAIT_LANGS.items() if first == spec.title), None)
698
+ if lang is None:
699
+ raise LogError(f"{path} 的标题认不出(「# 等你处理」或「# Waiting on you」),不往里写")
700
+ spec = WAIT_LANGS[lang]
701
+ categories = {text: slug for slug, text in spec.categories.items()}
702
+ items: list[WaitItem] = []
703
+ cur: WaitItem | None = None
704
+ last_key: str | None = None
705
+ for i, line in enumerate(lines):
706
+ m = ITEM_RE.match(line)
707
+ if m:
708
+ cur = WaitItem(int(m.group(2)), m.group(1) != " ", m.group(3), {}, i, i + 1)
709
+ items.append(cur)
710
+ last_key = None
711
+ continue
712
+ if cur is None or not line.strip():
713
+ continue
714
+ sub = SUB_VALUE_RE.match(line)
715
+ if sub and last_key in CODE_LIST_FIELDS:
716
+ values = cur.fields[last_key]
717
+ assert isinstance(values, list)
718
+ value = sub.group(1).strip()
719
+ if last_key == "commands":
720
+ value = next((value[len(p):] for p in spec.command_kinds.values() if value.startswith(p)), value)
721
+ values.append(value.removeprefix("`").removesuffix("`"))
722
+ cur.end = i + 1
723
+ continue
724
+ for key, label in spec.labels.items():
725
+ head = f" - {label}{spec.field_sep}"
726
+ if line.rstrip() != head.rstrip() and not line.startswith(head):
727
+ continue
728
+ value = line[len(head):].strip() if line.startswith(head) else ""
729
+ if key in CODE_LIST_FIELDS:
730
+ cur.fields[key] = [] if value in ("", spec.none) else [value.strip("`")]
731
+ elif key == "tickets":
732
+ cur.fields[key] = [] if value == spec.none else value.split(spec.list_sep)
733
+ elif key == "category":
734
+ cur.fields[key] = categories.get(value, value)
735
+ else:
736
+ cur.fields[key] = value
737
+ last_key = key
738
+ cur.end = i + 1
739
+ break
740
+ return lang, lines, items
741
+
742
+
743
+ def command_text(spec: WaitLang, command: str) -> str:
744
+ """一条回来后可输入的命令,前面注明在和 AI 的对话里输入(skill 命令)还是自己在终端里运行。"""
745
+ kind = "chat" if SKILL_INPUT_RE.match(command) else "terminal"
746
+ return f"{spec.command_kinds[kind]}`{command}`"
747
+
748
+
749
+ def render_item(spec: WaitLang, number: int, title: str, fields: dict[str, object]) -> list[str]:
750
+ """清单的一条:勾选框、编号、标题,接着每个字段一行;多值字段每个值一行照原样,命令前注明在哪儿输。"""
751
+ out = [f"- [ ] **{number}. {title}**"]
752
+ for key in WAIT_FIELDS:
753
+ if key not in fields:
754
+ continue
755
+ head = f" - {spec.labels[key]}{spec.field_sep}"
756
+ value = fields[key]
757
+ if key in CODE_LIST_FIELDS:
758
+ assert isinstance(value, list)
759
+ if value:
760
+ out.append(head.rstrip())
761
+ out.extend(f" - {command_text(spec, str(v)) if key == 'commands' else f'`{v}`'}" for v in value)
762
+ else:
763
+ out.append(head + spec.none)
764
+ elif key == "tickets":
765
+ assert isinstance(value, list)
766
+ out.append(head + (spec.list_sep.join(value) if value else spec.none))
767
+ elif key == "category":
768
+ out.append(head + spec.categories[str(value)])
769
+ else:
770
+ out.append(head + str(value))
771
+ return out
772
+
773
+
774
+ # ---------------------------------------------------------------------------
775
+ # 命令
776
+ # ---------------------------------------------------------------------------
777
+
778
+ def owners(rows: list[Row]) -> list[str | None]:
779
+ """每行归哪次运行:最近一条 start 行(含它自己)的对话编号;第一条 start 之前的归 None。"""
780
+ out: list[str | None] = []
781
+ current: str | None = None
782
+ for r in rows:
783
+ if r.phase == START_PHASE:
784
+ current = r.evidence
785
+ out.append(current)
786
+ return out
787
+
788
+
789
+ def trailing_others(rows: list[Row], session: str | None) -> list[Row]:
790
+ """末尾连续不是这次对话写的那几行;查不到对话编号时算全部。"""
791
+ if session is None:
792
+ return list(rows)
793
+ owner = owners(rows)
794
+ i = len(rows)
795
+ while i > 0 and owner[i - 1] != session:
796
+ i -= 1
797
+ return rows[i:]
798
+
799
+
800
+ def print_tail(path: Path) -> None:
801
+ lines = [l for l in path.read_text(encoding="utf-8").splitlines() if l.strip().startswith("|")]
802
+ tail = lines[2:][-TAIL_ROWS:]
803
+ print(f"最后 {len(tail)} 行:" if tail else "日志里还没有行")
804
+ for line in tail:
805
+ print(line)
806
+
807
+
808
+ def cmd_start(path: Path, lang_code: str | None, session: str | None) -> int:
809
+ existing = read_log(path)
810
+ if existing is None:
811
+ if lang_code is None:
812
+ raise LogError("日志还没有,首次写入要给 --lang zh 或 --lang en(跟着用户的语言)")
813
+ create_log(path, lang_code)
814
+ existing = (lang_code, [])
815
+ lang = LANGS[existing[0]]
816
+ rows = existing[1]
817
+ owner = owners(rows)
818
+ if session is not None and rows and owner[-1] == session:
819
+ start_at = next(r.raw_time for r in reversed(rows) if r.phase == START_PHASE)
820
+ print(f"这次对话已经写过 start 行({start_at}),不再写")
821
+ print_tail(path)
822
+ print("别的对话插写:没有")
823
+ return EXIT_OK
824
+ write_start(path, lang, rows, session)
825
+ return EXIT_OK
826
+
827
+
828
+ def write_start(path: Path, lang: Lang, rows: list[Row], session: str | None) -> None:
829
+ """写 start 行:先打印最后几行与有没有别的对话插写,结果格写前面不是这次对话写的那几行。"""
830
+ owner = owners(rows)
831
+ others = trailing_others(rows, session)
832
+ if session is None:
833
+ interleave = "别的对话插写:对话编号查不到,没法判断"
834
+ elif others and session in owner:
835
+ interleave = f"别的对话插写:有,{others[0].raw_time} 至 {others[-1].raw_time}"
836
+ else:
837
+ interleave = "别的对话插写:没有"
838
+ print_tail(path)
839
+ print(interleave)
840
+ if others:
841
+ result = lang.prior_range.format(a=others[0].raw_time, b=others[-1].raw_time)
842
+ else:
843
+ result = lang.log_begins
844
+ when = append_row(path, rows, [
845
+ START_PHASE, lang.start_decision, lang.start_why, session or lang.no_session, result,
846
+ ])
847
+ print(f"已写 start 行:{when}")
848
+
849
+
850
+ def cmd_add(path: Path, session: str | None, fields: list[str], mark: str | None,
851
+ refers: str = "", targets: list[str] | None = None) -> int:
852
+ """记一行;refers 为 supersede / ack 时在决定格前写指向 targets 的开头。"""
853
+ when = write_row(path, session, fields, mark, refers, targets)
854
+ print(f"已记:{when}")
855
+ return EXIT_OK
856
+
857
+
858
+ def write_row(path: Path, session: str | None, fields: list[str], mark: str | None,
859
+ refers: str = "", targets: list[str] | None = None) -> str:
860
+ """追加一行并返回它的时间;没写成就抛 LogError、日志不动。"""
861
+ existing = read_log(path)
862
+ if existing is None:
863
+ raise LogError("日志还没有:这次对话先跑 start(带 --lang)")
864
+ lang = LANGS[existing[0]]
865
+ rows = existing[1]
866
+ phase, decision, why, evidence, result = fields
867
+ if targets:
868
+ known = {r.raw_time for r in rows}
869
+ missing = [t for t in targets if t not in known]
870
+ if missing:
871
+ raise LogError(f"日志里没有这个时间的行:{'、'.join(missing)}")
872
+ if refers == "supersede":
873
+ prefix = lang.supersedes.format(t=targets[0])
874
+ elif len(targets) == 1:
875
+ prefix = lang.ack_one.format(t=targets[0])
876
+ else:
877
+ prefix = lang.ack_many.format(ts=lang.time_sep.join(targets))
878
+ decision = prefix + decision.strip()
879
+ if mark:
880
+ result = result.strip()
881
+ # 开头连着的同一种标记(中英哪套都认,可以混排)全部去掉再加一个,免得写成「拿不准:拿不准:」
882
+ # (工单 33);以另一种标记开头的不动,照样加在前面,这一行按 --mark 算
883
+ same = mark_prefixes(mark)
884
+ while result.startswith(same):
885
+ result = result[len(next(m for m in same if result.startswith(m))):].strip()
886
+ result = lang.marks[mark] + result
887
+ # 每次运行写的第一行必须是 start 行:这次对话没写过、或别的对话在这之后插写过,就照 start
888
+ # 先补一行(工单 24);放在所有检查之后,没写成的命令不留下孤零零的 start 行
889
+ owner = owners(rows)
890
+ no_session = {spec.no_session for spec in LANGS.values()}
891
+ if session is None and owner and owner[-1] is not None and owner[-1] not in no_session:
892
+ # 查不到编号而最后一段记的是某个具体对话:先补一行「查不到」的 start 行,免得这一行被算到
893
+ # 那个对话名下(工单 05);最后一段已经是「查不到」的就不补,几次没有编号的运行本就分不开
894
+ write_start(path, lang, rows, None)
895
+ reread = read_log(path)
896
+ assert reread is not None
897
+ rows = reread[1]
898
+ elif session is not None and (not rows or owner[-1] != session):
899
+ write_start(path, lang, rows, session)
900
+ if session in owner:
901
+ # 插写过:先让 AI 读完最后几行与插写报告再写(C 节),这一行不写
902
+ raise LogError("别的对话在这次对话写过之后插写过,已补 start 行:读完上面的最后几行与插写报告,"
903
+ "再跑一次同一条命令")
904
+ reread = read_log(path)
905
+ assert reread is not None
906
+ rows = reread[1]
907
+ when = append_row(path, rows, [phase, decision, why, evidence, result])
908
+ if refers == "supersede":
909
+ standing_after_supersede(path, existing[0], rows)
910
+ # 只提醒、不拒绝,同工单 22
911
+ for problem in self_check_problems(fields[1], result):
912
+ print(problem, file=sys.stderr)
913
+ if SELF_CHECK_RE.match(fields[1].strip()):
914
+ # 对照的是这一行之前这次运行写的各行(工单 31、32);两种提醒都出时「来自报告」那句只说一次
915
+ report = from_report_problems(rows, session, result)
916
+ for problem in unlisted_pointers(rows, session, result, mention_report=not report) + report:
917
+ print(problem, file=sys.stderr)
918
+ if existing[0] == "zh" and LATIN_RE.search(phase) and not HAN_RE.search(phase):
919
+ # 只提醒、不拒绝(工单 22);行写进去之后才说,没写成的不提
920
+ print(f"提醒:阶段格「{one_line(phase)}」是纯英文;这份日志是中文的,阶段格跟着用户的语言写"
921
+ "(如「03 审查」「开跑」)。这一行照样记下了。", file=sys.stderr)
922
+ return when
923
+
924
+
925
+ def standing_after_supersede(path: Path, lang: str, before: list[Row]) -> None:
926
+ """顶替之后长期指令新对不上的,在 stderr 提醒怎么写;只提醒、不拒绝(工单 26)。"""
927
+ orders = read_standing(path.with_name(STANDING_NAME))
928
+ reread = read_log(path)
929
+ assert reread is not None
930
+ old = reconcile_standing(before, orders)
931
+ new = [m for m in reconcile_standing(reread[1], orders) if m not in old]
932
+ if new:
933
+ print("\n".join([REPORT_LANGS[lang].superseded_standing] + [mismatch_text(lang, m) for m in new]),
934
+ file=sys.stderr)
935
+
936
+
937
+ def self_check_problems(decision: str, result: str) -> list[str]:
938
+ """自查行结果格的提醒:写了笼统字样;没列自查时打开了什么、没打开什么。不是自查行的不看。"""
939
+ if not SELF_CHECK_RE.match(decision.strip()):
940
+ return []
941
+ problems = []
942
+ if THOROUGH_RE.search(result):
943
+ problems.append("提醒:自查行的结果格写了「逐行」「全部对照」一类的笼统字样:不写这类字样,"
944
+ "只写读了聊天记录 X/N 行、自查时打开了什么、没打开什么。这一行照样记下了。")
945
+ if not (OPENED_RE.search(result) and NOT_OPENED_RE.search(result)):
946
+ problems.append("提醒:自查行的结果格没写「自查时打开:」或「没打开:」:列出自查时打开证据跑的命令"
947
+ "(或打开的证据),再写没打开哪些(都打开了写「没打开:无」)。这一行照样记下了。")
948
+ return problems
949
+
950
+
951
+ def not_opened_list(result: str) -> str:
952
+ """结果格里「没打开:」那一段:从标签起,到它后面的「自查时打开:」(若有)为止;没写标签就是空的。"""
953
+ m = NOT_OPENED_RE.search(result)
954
+ if m is None:
955
+ return ""
956
+ opened = OPENED_RE.search(result, m.end())
957
+ return result[m.end():opened.start() if opened else len(result)]
958
+
959
+
960
+ def unsuperseded_report_rows(rows: list[Row], session: str | None) -> bool:
961
+ """这次运行里有没有证据格带报告指针、结论又没写明来自报告(自己或顶替它的行)的行;分不出这次运行就当没有。"""
962
+ try:
963
+ picked = this_run(rows, session)[0]
964
+ except LogError:
965
+ return False
966
+ marked = {r.raw_time for r in rows if FROM_REPORT_MARK_RE.search(r.result)}
967
+ for r in rows:
968
+ m = SUPERSEDES_RE.match(r.decision)
969
+ if m and FROM_REPORT_MARK_RE.search(r.result):
970
+ marked.add(m.group(1) or m.group(2))
971
+ return any(r.raw_time not in marked and any(FROM_REPORT_RE.search(p) for p in pointers)
972
+ for r, pointers in evidence_pointers(picked))
973
+
974
+
975
+ def from_report_problems(rows: list[Row], session: str | None, result: str) -> list[str]:
976
+ """写自查行时:「没打开:」里说核过了,或列了报告而这次运行还有带报告指针的行没顶替成来自报告,提醒一句(工单 32)。"""
977
+ items = [i for i in POINTER_SEP_RE.split(not_opened_list(result)) if not SUPERSEDED_NOTE_RE.search(i)]
978
+ claimed = any(CHECKED_CLAIM_RE.search(i) for i in items)
979
+ listed = any(FROM_REPORT_RE.search(i) for i in items)
980
+ if not (claimed or (listed and unsuperseded_report_rows(rows, session))):
981
+ return []
982
+ return ["提醒:自查行的「没打开:」里列了子代理的报告,或写了「已当场复核」一类的话:结论只来自报告、"
983
+ "当场核不了的,要追加顶替行写「来自报告」,列进「没打开:」或写「已当场复核」都不算;当场核过的,"
984
+ "在「自查时打开:」里写出核它的那条命令。脚本点不出是哪一行,自己对照。这一行照样记下了。"]
985
+
986
+
987
+ def is_sha(text: str) -> bool:
988
+ return bool(re.search(r"\d", text)) and bool(re.search(r"[a-f]", text))
989
+
990
+
991
+ def is_file(token: str, ext: str) -> bool:
992
+ if ext.lower() not in FILE_EXTS:
993
+ return False
994
+ return "/" in token or not BARE_WORD_STEM_RE.fullmatch(token.rsplit(".", 1)[0])
995
+
996
+
997
+ def pointer_keys(text: str) -> list[tuple[str, str]]:
998
+ """一段证据里认得出的指针:(种类, 值),种类是 sha、path、waiting;认不出就是空的。"""
999
+ keys = [("sha", m.group(0)) for m in SHA_RE.finditer(text) if is_sha(m.group(0))]
1000
+ keys += [("path", m.group(0)) for m in PATH_RE.finditer(text) if is_file(m.group(0), m.group(1))]
1001
+ if WAITING_ITEM_RE.search(text):
1002
+ keys.append(("waiting", ""))
1003
+ return keys
1004
+
1005
+
1006
+ def split_pointers(evidence: str) -> list[str]:
1007
+ """证据格拆成一个个指针(工单 31),照原样的写法;拆不准时不拆,整段算一个。"""
1008
+ out: list[str] = []
1009
+ for piece in POINTER_SEP_RE.split(evidence.replace("\\|", "|")):
1010
+ piece = piece.strip()
1011
+ if not piece:
1012
+ continue
1013
+ parts = [p.strip() for p in POINTER_LIST_SEP_RE.split(piece)]
1014
+ if len(parts) > 1 and all(pointer_keys(p) for p in parts):
1015
+ out += parts
1016
+ else:
1017
+ out.append(piece)
1018
+ return out
1019
+
1020
+
1021
+ def is_self_check_row(decision: str) -> bool:
1022
+ """日志里的自查行,顶替、认可自查行的那几行也算。"""
1023
+ return bool(SELF_CHECK_RE.match(ACKS_RE.sub("", SUPERSEDES_RE.sub("", decision)).strip()))
1024
+
1025
+
1026
+ def evidence_pointers(picked: list[Row]) -> list[tuple[Row, list[str]]]:
1027
+ """这次运行各行的证据指针,供自查第 3 步照着打开;start 行与自查行自己的证据不列。"""
1028
+ return [(r, split_pointers(r.evidence)) for r in picked
1029
+ if r.phase != START_PHASE and not is_self_check_row(r.decision)]
1030
+
1031
+
1032
+ def mentioned(keys: list[tuple[str, str]], text: str) -> bool:
1033
+ """指针在清单文字里出现过没有:提交号互为前缀就算,文件写出路径或文件名就算(行号怎么写都不管),
1034
+ 「等你处理」写到就算。宁可少提醒:一段里认得出的指针有一个出现就算这一段出现了。"""
1035
+ shas = [m.group(0) for m in SHA_RE.finditer(text)]
1036
+ for kind, value in keys:
1037
+ if kind == "sha" and any(s.startswith(value) or value.startswith(s) for s in shas):
1038
+ return True
1039
+ if kind == "path" and (value in text or value.rsplit("/", 1)[-1] in text):
1040
+ return True
1041
+ if kind == "waiting" and WAITING_MENTION_RE.search(text):
1042
+ return True
1043
+ return False
1044
+
1045
+
1046
+ def unlisted_pointers(rows: list[Row], session: str | None, result: str, mention_report: bool = True) -> list[str]:
1047
+ """写自查行时:这次运行各行认得出的证据指针,既不在「自查时打开:」也不在「没打开:」里的,逐个提醒。
1048
+ 两个标签都没写时不对照(另有提醒);分不出这次运行的行时也不对照。"""
1049
+ labels = [m.start() for rx in (OPENED_RE, NOT_OPENED_RE) for m in [rx.search(result)] if m]
1050
+ if not labels:
1051
+ return []
1052
+ lists = result[min(labels):]
1053
+ try:
1054
+ picked = this_run(rows, session)[0]
1055
+ except LogError:
1056
+ return []
1057
+ missing: dict[str, list[str]] = {}
1058
+ for r, pointers in evidence_pointers(picked):
1059
+ for p in pointers:
1060
+ keys = pointer_keys(p)
1061
+ if keys and not mentioned(keys, lists):
1062
+ missing.setdefault(p, []).append(r.raw_time)
1063
+ problems = [
1064
+ f"提醒:证据「{p}」({'、'.join(times)} 那{'一' if len(times) == 1 else '几'}行)既不在「自查时打开:」"
1065
+ "也不在「没打开:」里。"
1066
+ for p, times in missing.items()
1067
+ ]
1068
+ if problems:
1069
+ report = ("结论只来自子代理报告、当场核不了的,追加顶替行写「来自报告」,只列进「没打开:」不算。"
1070
+ if mention_report else "")
1071
+ problems.append("提醒:上面的指针自查时打开了就写进「自查时打开:」,没打开就列进「没打开:」;" + report
1072
+ + "脚本只对照认得出的指针(提交号、文件、「等你处理」第几条),其余的自己对照。这一行照样记下了。")
1073
+ return problems
1074
+
1075
+
1076
+ def host_form(command: str) -> str:
1077
+ """skill 命令改成当前宿主的写法(pi、omp 是 `/skill:`);不是 skill 命令或认不出宿主时原样。"""
1078
+ m = SKILL_INPUT_RE.match(command)
1079
+ if m is None:
1080
+ return command
1081
+ # 延迟导入:cross_model_review 在模块顶层导入了本脚本
1082
+ from cross_model_review import detect_host, skill_input
1083
+ fixed = skill_input(m.group(1), detect_host()[0]) or command
1084
+ if fixed != command:
1085
+ print(f"提醒:--command「{command}」改成当前工具的写法「{fixed}」", file=sys.stderr)
1086
+ return fixed
1087
+
1088
+
1089
+ def cmd_wait(log_path: Path, wait_path: Path, session: str | None, title: str,
1090
+ item: dict[str, object], phase: str, evidence: str) -> int:
1091
+ """记进「等你处理」一条,同一条命令里先写对应的日志行(日志写不成就清单也不写)。
1092
+
1093
+ 先写日志再写清单(工单 04):清单那一步没写成时日志里多一行登记,pending 对账报出,
1094
+ 顶替那一行再重新登记就对上;反过来先写清单的话,断掉后清单里那条没有命令能清。
1095
+ """
1096
+ existing = read_log(log_path)
1097
+ if existing is None:
1098
+ raise LogError("日志还没有:这次对话先跑 start(带 --lang),清单的每一条都要有对应的日志行")
1099
+ placeholders = item["placeholders"]
1100
+ assert isinstance(placeholders, list)
1101
+ bad = [p for p in placeholders if not PLACEHOLDER_RE.match(p)]
1102
+ if bad:
1103
+ raise LogError(f"占位要写成「文件:行」:{'、'.join(bad)}")
1104
+ waiting = read_waiting(wait_path)
1105
+ # 清单还没有时跟着日志的语言,之后沿用
1106
+ lang_code = waiting[0] if waiting else existing[0]
1107
+ spec = WAIT_LANGS[lang_code]
1108
+ number = max((it.number for it in waiting[2]), default=0) + 1 if waiting else 1
1109
+ for key in ("if_ignored", "prereq", "irreversible"):
1110
+ if not item[key]:
1111
+ item[key] = spec.unknown
1112
+ when = write_row(log_path, session, [
1113
+ phase, title, str(item["why"]), evidence, LANGS[existing[0]].wait_added.format(n=number),
1114
+ ], None)
1115
+ item["log_time"] = when
1116
+ block = render_item(spec, number, title, item)
1117
+ if waiting is None:
1118
+ text = f"{spec.title}\n\n{spec.intro}\n"
1119
+ else:
1120
+ text = "\n".join(waiting[1]).rstrip("\n") + "\n"
1121
+ try:
1122
+ write_file(wait_path, text + "\n" + "\n".join(block) + "\n")
1123
+ except LogError as e:
1124
+ raise LogError(f"日志行 {when} 已写,「等你处理」第 {number} 条没写进清单({e}):先用 "
1125
+ f"`decision_log.py supersede --time {when}` 顶替那一行,再重新 wait") from e
1126
+ print(f"已记「等你处理」第 {number} 条;对应日志行:{when}")
1127
+ return EXIT_OK
1128
+
1129
+
1130
+ def cmd_resolve(log_path: Path, wait_path: Path, session: str | None, number: int,
1131
+ reply: str, phase: str, evidence: str, result: str | None) -> int:
1132
+ """勾掉一条、在末尾写答复,同时追加日志行;条目不删。"""
1133
+ waiting = read_waiting(wait_path)
1134
+ if waiting is None:
1135
+ raise LogError("这个任务还没有「等你处理」清单")
1136
+ lang_code, lines, items = waiting
1137
+ item = next((it for it in items if it.number == number), None)
1138
+ if item is None:
1139
+ raise LogError(f"「等你处理」里没有第 {number} 条")
1140
+ if item.done:
1141
+ raise LogError(f"「等你处理」第 {number} 条已经处理过了,不重复写答复")
1142
+ existing = read_log(log_path)
1143
+ if existing is None:
1144
+ raise LogError("日志还没有:这次对话先跑 start(带 --lang)")
1145
+ log_lang = LANGS[existing[0]]
1146
+ reply = one_line(reply)
1147
+ # 先写日志再勾清单(工单 04):清单那一步没写成时这一条还开着,pending 对账报出,再跑一次同一条命令;
1148
+ # 重跑时日志里已有没被顶替的勾掉行,就只勾清单、不写第二行
1149
+ logged = resolved_rows(existing[1]).get(number)
1150
+ if logged is not None:
1151
+ when = logged[1].raw_time
1152
+ print(f"日志里已有勾掉第 {number} 条的行({when}),不再写,只勾清单")
1153
+ else:
1154
+ when = write_row(log_path, session, [
1155
+ phase, log_lang.wait_resolved.format(n=number, title=item.title), reply, evidence,
1156
+ result if result is not None else log_lang.wait_resolved_result,
1157
+ ], None)
1158
+ spec = WAIT_LANGS[lang_code]
1159
+ lines[item.start] = lines[item.start].replace("- [ ]", "- [x]", 1)
1160
+ lines.insert(item.end, f" - {spec.labels['reply']}{spec.field_sep}{reply}")
1161
+ try:
1162
+ write_file(wait_path, "\n".join(lines) + "\n")
1163
+ except LogError as e:
1164
+ raise LogError(f"日志行 {when} 已写,「等你处理」第 {number} 条没勾上({e}):再跑一次同一条命令") from e
1165
+ print(f"已勾掉「等你处理」第 {number} 条;对应日志行:{when}")
1166
+ return EXIT_OK
1167
+
1168
+
1169
+ def cmd_standing(log_path: Path, orders_path: Path, session: str | None, order: str,
1170
+ why: str, phase: str, evidence: str, lang_code: str | None) -> int:
1171
+ """追加一条长期指令,同一条命令里先写对应的日志行;日志还没有时照 start 先开日志。
1172
+
1173
+ 先写日志再写表(同 wait 的写入顺序,工单 04):表那一步没写成时日志里多一行,pending 按编号对账报出;
1174
+ 日志与表对不上时不追加、退出 2 并给出修法,免得新的一条占用日志里那一行已经说过的编号。
1175
+ """
1176
+ order = one_line(order)
1177
+ if not order:
1178
+ raise LogError("--order 是空的:长期指令要写成一句话")
1179
+ existing = read_log(log_path)
1180
+ orders = read_standing(orders_path)
1181
+ problems = reconcile_standing(existing[1] if existing else [], orders)
1182
+ if problems:
1183
+ lang = existing[0] if existing else "zh"
1184
+ raise LogError("日志与长期指令表对不上,先照修法处理再追加:\n"
1185
+ + "\n".join(f"- {mismatch_text(lang, m)}" for m in problems))
1186
+ if existing is None:
1187
+ if lang_code is None:
1188
+ raise LogError("日志还没有,首次写入要给 --lang zh 或 --lang en(跟着用户的语言)")
1189
+ cmd_start(log_path, lang_code, session)
1190
+ existing = read_log(log_path)
1191
+ assert existing is not None
1192
+ number = max(orders, default=0) + 1
1193
+ when = write_row(log_path, session, [
1194
+ phase, order, why, evidence, LANGS[existing[0]].standing_added.format(n=number),
1195
+ ], None)
1196
+ text = orders_path.read_text(encoding="utf-8") if orders_path.is_file() else ""
1197
+ if text and not text.endswith("\n"):
1198
+ text += "\n"
1199
+ try:
1200
+ write_file(orders_path, text + f"{number}. {order}\n")
1201
+ except LogError as e:
1202
+ raise LogError(f"日志行 {when} 已写,长期指令第 {number} 条没写进表({e}):先用 "
1203
+ f"`decision_log.py supersede --time {when}` 顶替那一行,再重新 standing") from e
1204
+ print(f"已记进长期指令第 {number} 条;对应日志行:{when}")
1205
+ return EXIT_OK
1206
+
1207
+
1208
+ def row_json(r: Row) -> dict[str, str]:
1209
+ keys = ("time", "phase", "decision", "why", "evidence", "result")
1210
+ # 文件里转义过的竖线还原成原字
1211
+ return {k: v.replace("\\|", "|") for k, v in zip(keys, (
1212
+ r.raw_time, r.phase, r.decision, r.why, r.evidence, r.result,
1213
+ ))}
1214
+
1215
+
1216
+ def superseded_times(rows: list[Row]) -> set[str]:
1217
+ out: set[str] = set()
1218
+ for r in rows:
1219
+ m = SUPERSEDES_RE.match(r.decision)
1220
+ if m:
1221
+ out.add(m.group(1) or m.group(2))
1222
+ return out
1223
+
1224
+
1225
+ def acked_times(rows: list[Row]) -> set[str]:
1226
+ out: set[str] = set()
1227
+ for r in rows:
1228
+ m = ACKS_RE.match(r.decision)
1229
+ if m:
1230
+ out.update(ACK_TIME_SEP_RE.split(m.group(1) or m.group(2)))
1231
+ return out
1232
+
1233
+
1234
+ def mark_prefixes(mark: str) -> tuple[str, ...]:
1235
+ """某种标记的中英两种写法(去掉尾随空格),认标记时都用它。"""
1236
+ return tuple(spec.marks[mark].strip() for spec in LANGS.values())
1237
+
1238
+
1239
+ def marked_rows(rows: list[Row], mark: str) -> list[Row]:
1240
+ """结果格以某种标记开头的行,中英两种写法都认。"""
1241
+ return [r for r in rows if r.result.startswith(mark_prefixes(mark))]
1242
+
1243
+
1244
+ def print_rows(header: tuple[str, ...], rows: list[Row]) -> None:
1245
+ print(format_row(list(header)))
1246
+ print(format_row(["---"] * 6))
1247
+ for r in rows:
1248
+ print(format_row([r.raw_time, r.phase, r.decision, r.why, r.evidence, r.result]))
1249
+
1250
+
1251
+ def resolved_rows(rows: list[Row]) -> dict[int, tuple[str, Row]]:
1252
+ """没被顶替的勾掉行,按条目编号:(标题, 最后那一行)。对账与重跑 resolve 共用。"""
1253
+ gone = superseded_times(rows)
1254
+ out: dict[int, tuple[str, Row]] = {}
1255
+ for r in rows:
1256
+ m = WAIT_RESOLVED_RE.match(r.decision)
1257
+ if m and r.raw_time not in gone:
1258
+ out[int(m.group(1) or m.group(3))] = ((m.group(2) or m.group(4) or "").replace("\\|", "|"), r)
1259
+ return out
1260
+
1261
+
1262
+ def reconcile(rows: list[Row], items: list[WaitItem]) -> list[dict[str, object]]:
1263
+ """日志与「等你处理」清单对账(工单 04,prd E 节),对不上的逐条给出 {kind, number, title, log_time}。
1264
+
1265
+ 登记按条目的「对应日志行」对上结果格是登记写法的那一行,勾掉按编号对上决定格是勾掉写法的行;
1266
+ 被顶替的行不算。四种:log-only-added(日志说登记了、清单里没有对应这一行的条目)、
1267
+ list-only-added(条目在日志里没有对应的登记行)、log-only-resolved(日志说勾掉了、清单里没有
1268
+ 勾上的这一条)、list-only-resolved(条目勾上了、日志里没有勾掉它的行)。
1269
+ """
1270
+ gone = superseded_times(rows)
1271
+ added: dict[str, tuple[int, Row]] = {}
1272
+ for r in rows:
1273
+ m = WAIT_ADDED_RE.match(r.result)
1274
+ if m and r.raw_time not in gone:
1275
+ added[r.raw_time] = (int(m.group(1) or m.group(2)), r)
1276
+ resolved = resolved_rows(rows)
1277
+ item_times = {it.fields.get("log_time") for it in items}
1278
+ by_number = {it.number: it for it in items}
1279
+ out: list[dict[str, object]] = []
1280
+
1281
+ def found(kind: str, number: int, title: str, log_time: object) -> None:
1282
+ out.append({"kind": kind, "number": number, "title": title, "log_time": log_time})
1283
+
1284
+ for when, (number, r) in added.items():
1285
+ if when not in item_times:
1286
+ found("log-only-added", number, r.decision.replace("\\|", "|"), when)
1287
+ for it in items:
1288
+ if it.fields.get("log_time") not in added:
1289
+ found("list-only-added", it.number, it.title, it.fields.get("log_time") or None)
1290
+ for number, (title, r) in resolved.items():
1291
+ listed = by_number.get(number)
1292
+ if listed is None or not listed.done:
1293
+ found("log-only-resolved", number, title, r.raw_time)
1294
+ for it in items:
1295
+ if it.done and it.number not in resolved:
1296
+ found("list-only-resolved", it.number, it.title, None)
1297
+ return out
1298
+
1299
+
1300
+ def read_standing(path: Path) -> dict[int, str]:
1301
+ """长期指令表的各条,按编号;表还没有时为空。"""
1302
+ if not path.is_file():
1303
+ return {}
1304
+ found = (STANDING_ITEM_RE.match(line) for line in path.read_text(encoding="utf-8").splitlines())
1305
+ return {int(m.group(1)): m.group(2) for m in found if m}
1306
+
1307
+
1308
+ def reconcile_standing(rows: list[Row], orders: dict[int, str]) -> list[dict[str, object]]:
1309
+ """日志与长期指令表按编号对账(工单 07),形状同 reconcile;被顶替的行不算。两种:
1310
+ log-only-standing(日志说进了第 N 条、表里没有第 N 条)、list-only-standing(表里第 N 条在日志里没有对应的行)。
1311
+ 顶替行结果格没写编号时,若被顶替那一行(顺着顶替链)说的第 N 条表里确有,就承接第 N 条的登记(工单 26);
1312
+ 表里没有的不承接,「顶替这一行再重新 standing」的修法照旧。
1313
+ """
1314
+ gone = superseded_times(rows)
1315
+ by_time = {r.raw_time: r for r in rows}
1316
+
1317
+ def standing_number(r: Row, seen: set[str]) -> int | None:
1318
+ m = STANDING_ADDED_RE.match(r.result)
1319
+ if m:
1320
+ return int(m.group(1) or m.group(2))
1321
+ s = SUPERSEDES_RE.match(r.decision)
1322
+ target = by_time.get(s.group(1) or s.group(2)) if s else None
1323
+ if target is None or target.raw_time in seen:
1324
+ return None
1325
+ n = standing_number(target, seen | {r.raw_time})
1326
+ return n if n in orders else None
1327
+
1328
+ logged: dict[int, Row] = {}
1329
+ for r in rows:
1330
+ n = standing_number(r, set())
1331
+ if n is not None and r.raw_time not in gone:
1332
+ logged[n] = r
1333
+ out: list[dict[str, object]] = []
1334
+ for number, r in logged.items():
1335
+ if number not in orders:
1336
+ out.append({"kind": "log-only-standing", "number": number,
1337
+ "title": r.decision.replace("\\|", "|"), "log_time": r.raw_time})
1338
+ for number, text in orders.items():
1339
+ if number not in logged:
1340
+ out.append({"kind": "list-only-standing", "number": number, "title": text, "log_time": None})
1341
+ return out
1342
+
1343
+
1344
+ def mismatch_text(lang: str, m: dict[str, object]) -> str:
1345
+ """对不上的一处,措辞跟着 lang;没有对应日志行时写「无」。"""
1346
+ when = m["log_time"] or WAIT_LANGS[lang].none
1347
+ return REPORT_LANGS[lang].mismatch[str(m["kind"])].format(time=when, n=m["number"], title=m["title"])
1348
+
1349
+
1350
+ def item_json(item: WaitItem) -> dict[str, object]:
1351
+ out: dict[str, object] = {"number": item.number, "done": item.done, "title": item.title}
1352
+ for key in WAIT_FIELDS:
1353
+ default: object = [] if key in CODE_LIST_FIELDS or key == "tickets" else None
1354
+ out[key] = item.fields.get(key, default)
1355
+ return out
1356
+
1357
+
1358
+ LOG_REVIEW_DIR = "log-review"
1359
+ REPORT_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
1360
+ FINDING_HEADING_RE = re.compile(r"^#{2,6}\s+(\d+)[.、]\s*(.+?)\s*$")
1361
+ HANDLED_ITEM_RE = re.compile(r"(?:要改|must fix)\s*第?\s*(\d+)", re.IGNORECASE)
1362
+
1363
+
1364
+ def log_review_findings(text: str) -> tuple[list[tuple[int, str]], set[int]]:
1365
+ """一份日志复查报告:「要改」标题下的 `### N. 标题`,以及末尾「已处理」一节里写到的编号。"""
1366
+ findings: list[tuple[int, str]] = []
1367
+ handled: set[int] = set()
1368
+ section: str | None = None
1369
+ level = 0
1370
+ in_fence = False
1371
+ for line in text.splitlines():
1372
+ if line.lstrip().startswith(("```", "~~~")):
1373
+ in_fence = not in_fence
1374
+ continue
1375
+ if in_fence:
1376
+ continue
1377
+ head = REPORT_HEADING_RE.match(line)
1378
+ if head and (section is None or len(head.group(1)) <= level):
1379
+ title = head.group(2).strip().lower()
1380
+ if title.startswith(("要改", "must fix")):
1381
+ section, level = "must", len(head.group(1))
1382
+ elif title.startswith(("已处理", "handled")):
1383
+ section, level = "handled", len(head.group(1))
1384
+ else:
1385
+ section, level = None, 0
1386
+ continue
1387
+ if section == "must":
1388
+ m = FINDING_HEADING_RE.match(line)
1389
+ if m:
1390
+ findings.append((int(m.group(1)), m.group(2).strip("*_ ")))
1391
+ elif section == "handled":
1392
+ handled.update(int(n) for n in HANDLED_ITEM_RE.findall(line))
1393
+ return findings, handled
1394
+
1395
+
1396
+ def open_must_fix(task: Path) -> list[dict[str, object]]:
1397
+ """任务目录 log-review/ 里各份报告还没处理的「要改」,按报告文件名排。"""
1398
+ folder = task / LOG_REVIEW_DIR
1399
+ out: list[dict[str, object]] = []
1400
+ for f in sorted(folder.glob("*.md")) if folder.is_dir() else []:
1401
+ findings, handled = log_review_findings(f.read_text(encoding="utf-8"))
1402
+ out += [
1403
+ {"report": f"{LOG_REVIEW_DIR}/{f.name}", "number": n, "title": title}
1404
+ for n, title in findings if n not in handled
1405
+ ]
1406
+ return out
1407
+
1408
+
1409
+ @dataclass(frozen=True)
1410
+ class ReportLang:
1411
+ headings: tuple[str, str, str, str] # prd L 节四块,顺序固定
1412
+ item: str # {n} {title}
1413
+ mismatches: str # 第一块里对不上的那一项(工单 04)
1414
+ mismatch: dict[str, str] # reconcile 的四种 kind,各带 {time} {n} {title};pending 文本也用它
1415
+ superseded_standing: str # supersede 之后长期指令新对不上时 stderr 提醒的开头(工单 26)
1416
+ human_tickets: str
1417
+ reports: str
1418
+ must_fix: str
1419
+ must_fix_item: str # {report} {n} {title}
1420
+ fill: str # {what}:底稿里留给 AI 补的地方
1421
+ review_fill: str
1422
+ states: dict[str, str] # 工单状态的写法;skipped 带 {n}
1423
+ skipped_unmatched: str
1424
+ totals: str # {n} 与 states 的各个键
1425
+ tickets_fill: str
1426
+ closing_fill: str
1427
+ terms: dict[str, str] # 小白模式开着时每个流程说法的一句大白话;键同 pending --json 的四项
1428
+
1429
+
1430
+ REPORT_LANGS: dict[str, ReportLang] = {
1431
+ "zh": ReportLang(
1432
+ headings=("等你处理", "日志复查挑出的问题", "待你确认的条目", "每张工单的状态"),
1433
+ item="第 {n} 条:{title}",
1434
+ mismatches="日志与清单对不上(按没处理算)",
1435
+ mismatch={
1436
+ "log-only-added": "日志 {time} 那一行说进了「等你处理」第 {n} 条「{title}」,清单里没有对应这一行的条目;"
1437
+ "修法:`decision_log.py supersede --time {time}` 顶替这一行,再用 `decision_log.py wait` 重新登记",
1438
+ "list-only-added": "清单第 {n} 条「{title}」在日志里没有对应的登记行(对应日志行:{time})",
1439
+ "log-only-resolved": "日志 {time} 那一行说勾掉了「等你处理」第 {n} 条「{title}」,清单里没有勾上的第 {n} 条;"
1440
+ "修法:再跑一次 `decision_log.py resolve --item {n}`",
1441
+ "list-only-resolved": "清单第 {n} 条「{title}」已勾掉,日志里没有勾掉它的行",
1442
+ "log-only-standing": "日志 {time} 那一行说进了长期指令第 {n} 条「{title}」,长期指令表里没有第 {n} 条;"
1443
+ "修法:`decision_log.py supersede --time {time}` 顶替这一行,再用 `decision_log.py standing` 重新记",
1444
+ "list-only-standing": "长期指令表第 {n} 条「{title}」在日志里没有对应的行;"
1445
+ "修法:用 `decision_log.py add` 补一行,决定格写这条指令、结果格写「进长期指令第 {n} 条」",
1446
+ },
1447
+ superseded_standing="提醒:顶替之后日志与长期指令表对不上(这一行照样记下了)。顶替长期指令那一行时,结果格可以写说明、"
1448
+ "不写编号,顶替行承接被顶替那一行的编号;结果格写「进长期指令第 N 条」时,N 照被顶替那一行的编号。"
1449
+ "现在对不上的:",
1450
+ human_tickets="要人做、还没做的工单",
1451
+ reports="报告",
1452
+ must_fix="还没处理的「要改」",
1453
+ must_fix_item="{report} 第 {n} 条:{title}",
1454
+ fill="(补:{what})",
1455
+ review_fill="照报告先写「要改」「值得考虑」;没做成时写明为什么、怎么补",
1456
+ states={"done": "做完", "doing": "进行中", "skipped": "跳过(「等你处理」第 {n} 条)",
1457
+ "human": "等人做", "untouched": "没动"},
1458
+ skipped_unmatched="跳过(清单里没对上条目)",
1459
+ totals="共 {n} 张:做完 {done}、进行中 {doing}、跳过 {skipped}、等人做 {human}、没动 {untouched}",
1460
+ tickets_fill="每张的提交号与各轴审查结论;跳过(清单第几条)没对上的,照工单 `## Comments` 补上第几条",
1461
+ closing_fill="改动在哪条分支、什么都没推送也没并进主线;下一步:先在对话里答复上面哪几条(例如「第 1 条按 B」),"
1462
+ "答复完想让 AI 接着做剩下的再输入哪条命令,不用一直跑下去",
1463
+ terms={
1464
+ "waiting": "「等你处理」是 AI 自己办不了、得你来的事(比如缺密码、要你拍板),每条写着要你做什么、"
1465
+ "AI 先按哪个做了;你处理完一条就勾掉一条。",
1466
+ "needs_you": "「待你确认」是 AI 替你做的、你自己就能判断的选择(比如需求说得含糊时选了推荐的做法),"
1467
+ "请你看一眼:没问题就不用管,不同意就直接说。",
1468
+ "unsure": "「拿不准」是 AI 自己心里没底的技术判断,你未必好判断,可以请另一家公司的 AI 再看一遍。",
1469
+ "log_review_must_fix": "「日志复查」是另请一家公司的 AI 只看这次的决定日志(AI 边干边记的做了什么、为什么、"
1470
+ "证据在哪)和聊天记录,挑说做了其实没做、证据站不住的地方;"
1471
+ "其中「要改」是它认为不改就不该合进正式代码的问题。",
1472
+ },
1473
+ ),
1474
+ "en": ReportLang(
1475
+ headings=("Waiting on you", "What the log review found", "Waiting for your confirmation", "Each ticket"),
1476
+ item="Item {n}: {title}",
1477
+ mismatches="The log and the waiting-on-you list do not match (counted as not handled)",
1478
+ mismatch={
1479
+ "log-only-added": "the log row {time} says item {n} \"{title}\" was added to waiting-on-you, "
1480
+ "but the list has no item for that row; to fix: `decision_log.py supersede --time {time}` "
1481
+ "that row, then `decision_log.py wait` again",
1482
+ "list-only-added": "list item {n} \"{title}\" has no matching row in the log (its log row: {time})",
1483
+ "log-only-resolved": "the log row {time} says waiting-on-you item {n} \"{title}\" was checked off, "
1484
+ "but the list has no checked-off item {n}; to fix: run `decision_log.py resolve --item {n}` again",
1485
+ "list-only-resolved": "list item {n} \"{title}\" is checked off, but the log has no row checking it off",
1486
+ "log-only-standing": "the log row {time} says standing order {n} \"{title}\" was added, but standing-orders.md "
1487
+ "has no line {n}; to fix: `decision_log.py supersede --time {time}` that row, "
1488
+ "then `decision_log.py standing` again",
1489
+ "list-only-standing": "standing order {n} \"{title}\" in standing-orders.md has no matching row in the log; "
1490
+ "to fix: `decision_log.py add` a row whose decision is that order and whose result is "
1491
+ "`added to standing orders as item {n}`",
1492
+ },
1493
+ superseded_standing="note: after this supersede the log and standing-orders.md do not match (the row was logged "
1494
+ "anyway). When superseding a standing-order row, the result may be any note without a number: "
1495
+ "the new row takes over the number of the row it supersedes; if the result says "
1496
+ "`added to standing orders as item N`, N must be that row's number. Now not matching:",
1497
+ human_tickets="Tickets waiting on a person, not done yet",
1498
+ reports="Reports",
1499
+ must_fix="Must fix not yet handled",
1500
+ must_fix_item="{report} item {n}: {title}",
1501
+ fill="(fill in: {what})",
1502
+ review_fill="Must fix and Worth considering first, from the report; when it was not done, why and how to make it up",
1503
+ states={"done": "done", "doing": "in progress", "skipped": "skipped (waiting-on-you item {n})",
1504
+ "human": "waiting on a person", "untouched": "untouched"},
1505
+ skipped_unmatched="skipped (no waiting-on-you item matched)",
1506
+ totals="{n} tickets: {done} done, {doing} in progress, {skipped} skipped, {human} waiting on a person, {untouched} untouched",
1507
+ tickets_fill="each ticket's commit SHAs and review verdict per axis; a skip with no item matched gets its waiting-on-you item N from the ticket's `## Comments`",
1508
+ closing_fill="which branch the changes are on, that nothing was pushed or merged into the base branch; "
1509
+ "what to do next: which items above to answer first, right here in the chat (e.g. \"item 1: go with B\"), "
1510
+ "then which command to type once answered if the AI should carry on with the rest — nothing has to keep running",
1511
+ terms={
1512
+ "waiting": "\"Waiting on you\" means things the AI could not do without you (a missing key, a choice only you "
1513
+ "can make): each says what you need to do and what the AI went ahead with meanwhile; "
1514
+ "tick one off once it is handled.",
1515
+ "needs_you": "\"Waiting for your confirmation\" means a choice the AI made for you that you can judge yourself "
1516
+ "(say, picking the recommended reading of a vague request): look it over; if it is fine there "
1517
+ "is nothing to do, if not, just say so.",
1518
+ "unsure": "\"Unsure\" means a technical call the AI is not confident about and you may not be able to judge "
1519
+ "either; an AI from another company can take a second look.",
1520
+ "log_review_must_fix": "\"The log review\" means an AI from another company read only this run's decision log "
1521
+ "(the AI's running notes of what it did, why, and where the proof is) and the chat, "
1522
+ "looking for things said to be done that were not and proof that does not hold up; "
1523
+ "\"Must fix\" is what it says has to change before this goes into the main code.",
1524
+ },
1525
+ ),
1526
+ }
1527
+
1528
+
1529
+ TICKET_HEADING_RE = re.compile(r"^# \d+\s*[—-]\s*(.+?)\s*$", re.MULTILINE)
1530
+
1531
+
1532
+ def ticket_states(task: Path, items: list[WaitItem]) -> list[tuple[str, str, str, int | None]]:
1533
+ """每张工单:(文件名, 编号与标题, 状态, 跳过时对上的清单条目)。解析、状态名与跳过评论的写法都取自工单脚本。
1534
+
1535
+ 跳过 = `tickets.py skip` 留下的样子:Status 是 ready-for-human、还没做完、`## Comments` 里有它写的
1536
+ 跳过评论。条目先按评论里日志行的时间对清单条目的「对应日志行」,对不上再找 `--ticket` 写着这张、
1537
+ 类别是跳过的条目。
1538
+ """
1539
+ import tickets # tickets.py 在模块顶层 import 本脚本,放到这里免得循环导入
1540
+
1541
+ try:
1542
+ found = tickets.load_tickets(task / "issues")
1543
+ except tickets.TicketError as e:
1544
+ raise LogError(f"工单读不出:{e}") from e
1545
+ skip_res = [
1546
+ re.compile(re.escape(texts["skip"]).replace(re.escape("{reason}"), ".*").replace(re.escape("{time}"), r"(\S+)"))
1547
+ for texts in tickets.COMMENT_TEXT.values()
1548
+ ]
1549
+ by_time = {it.fields.get("log_time"): it.number for it in items}
1550
+ out: list[tuple[str, str, str, int | None]] = []
1551
+ for t in found:
1552
+ text = t.path.read_text(encoding="utf-8")
1553
+ heading = TICKET_HEADING_RE.search(text)
1554
+ name = f"{t.num:02d} {heading.group(1) if heading else t.label}"
1555
+ times = [m.group(1) for r in skip_res for m in r.finditer(text)]
1556
+ if t.impl == "done":
1557
+ out.append((t.label, name, "done", None))
1558
+ elif t.impl == "doing":
1559
+ out.append((t.label, name, "doing", None))
1560
+ elif t.status == tickets.STATUS_HUMAN and times:
1561
+ number = next((by_time[x] for x in reversed(times) if x in by_time), None)
1562
+ if number is None:
1563
+ number = next((it.number for it in items
1564
+ if it.fields.get("category") in ("skipped-ticket", "legal")
1565
+ and any(v.strip().isdigit() and int(v) == t.num
1566
+ for v in it.fields.get("tickets") or [])), None)
1567
+ out.append((t.label, name, "skipped", number))
1568
+ elif t.status == tickets.STATUS_HUMAN:
1569
+ out.append((t.label, name, "human", None))
1570
+ else:
1571
+ out.append((t.label, name, "untouched", None))
1572
+ return out
1573
+
1574
+
1575
+ def print_report(lang: str, all_items: list[WaitItem], header: tuple[str, ...], needs_you: list[Row],
1576
+ must_fix: list[dict[str, object]], task: Path, noob: bool = False,
1577
+ mismatches: list[dict[str, object]] | None = None) -> None:
1578
+ """汇报底稿:prd L 节的四块按顺序、措辞跟着日志的语言;任务目录里有的填上,其余标「补」。
1579
+ 小白模式开着时前三块标题下各先附一句大白话,第三块再加「拿不准」那句(工单 23)。
1580
+ 日志与清单对不上的列在第一块、按没处理算(工单 04)。"""
1581
+ rep, spec = REPORT_LANGS[lang], WAIT_LANGS[lang]
1582
+ sep = spec.field_sep
1583
+ items = [it for it in all_items if not it.done]
1584
+ states = ticket_states(task, all_items)
1585
+ human = [label for label, _, state, _ in states if state in ("human", "skipped")]
1586
+ folder = task / LOG_REVIEW_DIR
1587
+ reports = [f"{LOG_REVIEW_DIR}/{f.name}" for f in sorted(folder.glob("*.md"))] if folder.is_dir() else []
1588
+ def heading(i: int, *terms: str) -> list[str]:
1589
+ return [f"## {rep.headings[i]}", ""] + ([line for t in terms for line in (rep.terms[t], "")] if noob else [])
1590
+
1591
+ out: list[str] = heading(0, "waiting")
1592
+ for it in items:
1593
+ commands = spec.list_sep.join(command_text(spec, str(c)) for c in it.fields.get("commands") or []) or spec.none
1594
+ out += [
1595
+ f"- {rep.item.format(n=it.number, title=it.title)}",
1596
+ f" - {spec.labels['ask']}{sep}{it.fields.get('ask', '')}",
1597
+ f" - {spec.labels['default']}{sep}{it.fields.get('default', '')}",
1598
+ f" - {spec.labels['commands']}{sep}{commands}",
1599
+ ]
1600
+ if mismatches:
1601
+ out.append(f"- {rep.mismatches}{sep.rstrip()}")
1602
+ out += [f" - {mismatch_text(lang, m)}" for m in mismatches]
1603
+ if not items and not mismatches:
1604
+ out.append(f"- {spec.none}")
1605
+ out.append(f"- {rep.human_tickets}{sep}{spec.list_sep.join(human) or spec.none}")
1606
+ out += ["", *heading(1, "log_review_must_fix")]
1607
+ out.append(f"- {rep.reports}{sep}{spec.list_sep.join(reports) or spec.none}")
1608
+ if must_fix:
1609
+ out.append(f"- {rep.must_fix}{sep.rstrip()}")
1610
+ out += [f" - {rep.must_fix_item.format(report=f['report'], n=f['number'], title=f['title'])}"
1611
+ for f in must_fix]
1612
+ else:
1613
+ out.append(f"- {rep.must_fix}{sep}{spec.none}")
1614
+ out.append(f"- {rep.fill.format(what=rep.review_fill)}")
1615
+ # 底稿没有「拿不准」一块,它那句跟在同为日志标记的「待你确认」后面,汇报时提到它也有解释可用
1616
+ out += ["", *heading(2, "needs_you", "unsure")]
1617
+ if needs_you:
1618
+ out += [format_row([header[0], header[2], header[3]]), format_row(["---"] * 3)]
1619
+ out += [format_row([r.raw_time, r.decision, r.why]) for r in needs_you]
1620
+ else:
1621
+ out.append(f"- {spec.none}")
1622
+ out += ["", f"## {rep.headings[3]}", ""]
1623
+ for _, name, state, number in states:
1624
+ text = rep.skipped_unmatched if state == "skipped" and number is None else rep.states[state].format(n=number)
1625
+ out.append(f"- {name}{sep}{text}")
1626
+ if states:
1627
+ counts = {key: sum(1 for _, _, state, _ in states if state == key) for key in rep.states}
1628
+ out.append(f"- {rep.totals.format(n=len(states), **counts)}")
1629
+ else:
1630
+ out.append(f"- {spec.none}")
1631
+ out.append(f"- {rep.fill.format(what=rep.tickets_fill)}")
1632
+ out += ["", rep.fill.format(what=rep.closing_fill)]
1633
+ print("\n".join(out))
1634
+
1635
+
1636
+ def cmd_pending(log_path: Path, wait_path: Path, as_json: bool, as_report: bool = False, noob: bool = False) -> int:
1637
+ """只读:还没处理的条目,以及还没被认可或顶替的「待你确认」「拿不准」行;供汇报与验收用。"""
1638
+ waiting = read_waiting(wait_path)
1639
+ open_items: list[WaitItem] = []
1640
+ lines: list[str] = []
1641
+ if waiting is not None:
1642
+ lines = waiting[1]
1643
+ open_items = [it for it in waiting[2] if not it.done]
1644
+ existing = read_log(log_path)
1645
+ header = LANGS[existing[0] if existing else "zh"].header
1646
+ rows = existing[1] if existing else []
1647
+ closed = superseded_times(rows) | acked_times(rows)
1648
+ needs_you = [r for r in marked_rows(rows, "needs-you") if r.raw_time not in closed]
1649
+ unsure = [r for r in marked_rows(rows, "unsure") if r.raw_time not in closed]
1650
+ must_fix = open_must_fix(log_path.parent)
1651
+ wait_mismatches = reconcile(rows, waiting[2] if waiting else [])
1652
+ standing_mismatches = reconcile_standing(rows, read_standing(log_path.with_name(STANDING_NAME)))
1653
+ mismatches = wait_mismatches + standing_mismatches
1654
+ lang = existing[0] if existing else (waiting[0] if waiting else "zh")
1655
+ if as_report:
1656
+ print_report(lang, waiting[2] if waiting else [], LANGS[lang].header, needs_you, must_fix, log_path.parent, noob,
1657
+ mismatches)
1658
+ return EXIT_OK
1659
+ if as_json:
1660
+ out: dict[str, object] = {
1661
+ "waiting": [item_json(it) for it in open_items],
1662
+ "needs_you": [row_json(r) for r in needs_you],
1663
+ "unsure": [row_json(r) for r in unsure],
1664
+ "log_review_must_fix": must_fix,
1665
+ "mismatches": mismatches,
1666
+ }
1667
+ if noob:
1668
+ out["terms"] = REPORT_LANGS[lang].terms
1669
+ print(json.dumps(out, ensure_ascii=False, indent=2))
1670
+ return EXIT_OK
1671
+ if open_items:
1672
+ print(f"「等你处理」里还没处理的:共 {len(open_items)} 条")
1673
+ for it in open_items:
1674
+ print()
1675
+ print("\n".join(lines[it.start:it.end]))
1676
+ else:
1677
+ print("「等你处理」里还没处理的:没有")
1678
+ print()
1679
+ if wait_mismatches:
1680
+ print(f"日志与「等你处理」清单对不上的:共 {len(wait_mismatches)} 处,对不上的条目按没处理算")
1681
+ for m in wait_mismatches:
1682
+ print(f"- {mismatch_text(lang, m)}")
1683
+ else:
1684
+ print("日志与「等你处理」清单对不上的:没有")
1685
+ if standing_mismatches:
1686
+ print(f"日志与长期指令表对不上的:共 {len(standing_mismatches)} 处,修好前 standing 不追加")
1687
+ for m in standing_mismatches:
1688
+ print(f"- {mismatch_text(lang, m)}")
1689
+ for name, picked in (("待你确认", needs_you), ("拿不准", unsure)):
1690
+ print()
1691
+ if not picked:
1692
+ print(f"还没被认可或顶替的「{name}」行:没有")
1693
+ continue
1694
+ print(f"还没被认可或顶替的「{name}」行:共 {len(picked)} 行")
1695
+ print_rows(header, picked)
1696
+ print()
1697
+ if not must_fix:
1698
+ print("日志复查报告里还没处理的「要改」:没有")
1699
+ return EXIT_OK
1700
+ print(f"日志复查报告里还没处理的「要改」:共 {len(must_fix)} 条")
1701
+ for f in must_fix:
1702
+ print(f"- {f['report']} 第 {f['number']} 条:{f['title']}")
1703
+ return EXIT_OK
1704
+
1705
+
1706
+ def cmd_unsure(path: Path, as_json: bool) -> int:
1707
+ existing = read_log(path)
1708
+ if existing is None:
1709
+ raise LogError("日志还没有:这个任务还没有决定日志")
1710
+ lang = LANGS[existing[0]]
1711
+ rows = existing[1]
1712
+ # 只排除被顶替的:用户认可不等于技术上的没底消除了
1713
+ superseded = superseded_times(rows)
1714
+ picked = [r for r in marked_rows(rows, "unsure") if r.raw_time not in superseded]
1715
+ if as_json:
1716
+ print(json.dumps({"rows": [row_json(r) for r in picked]}, ensure_ascii=False, indent=2))
1717
+ return EXIT_OK
1718
+ if not picked:
1719
+ print("没有「拿不准」的行(已被顶替的不算)")
1720
+ return EXIT_OK
1721
+ print(f"「拿不准」的行:共 {len(picked)} 行(已被顶替的不算)")
1722
+ print(format_row(list(lang.header)))
1723
+ print(format_row(["---"] * 6))
1724
+ for r in picked:
1725
+ print(format_row([r.raw_time, r.phase, r.decision, r.why, r.evidence, r.result]))
1726
+ return EXIT_OK
1727
+
1728
+
1729
+ def transcript_lines(session: str | None, start: str, lang: Lang) -> dict[str, object]:
1730
+ """日志自查读聊天记录的那条命令与它此刻输出的总行数(工单 28),只是参考数;数不出时给原因、不猜。
1731
+
1732
+ command 为 None 表示没去调 atw:编号查不到、手动设置的原样值、atw mem 读不了的平台、--tools
1733
+ 读法没验证过的平台(分法照 cross_model_review 的 _chat_problem)。调了但没数出来时 lines 为 None。
1734
+ """
1735
+ if session is None:
1736
+ return {"command": None, "lines": None, "problem": lang.no_session}
1737
+ # 延迟导入:cross_model_review 在模块顶层导入了本脚本
1738
+ from cross_model_review import ATW_CONTEXT_PREFIXES, MEM_CONTEXT_PREFIXES, _MEM_NOT_FOUND, _context_from_override
1739
+ platform = session.partition("_")[0] if "_" in session else ""
1740
+ unread = None
1741
+ if platform not in ATW_CONTEXT_PREFIXES:
1742
+ unread = f"编号 {session} 不带 ATW 认得的平台前缀,多半是 ATW_CONTEXT_ID 手动设置的原样值"
1743
+ elif platform not in MEM_CONTEXT_PREFIXES:
1744
+ unread = f"编号 {session} 是 ATW 认得的平台 {platform},但 atw mem 读不了它的会话"
1745
+ elif platform not in VERIFIED_TOOLS_PLATFORMS:
1746
+ unread = f"平台 {platform} 不在 atw mem 的 --tools 读法验证过的四个(Claude Code、Codex、pi、omp)之内"
1747
+ if unread:
1748
+ return {"command": None, "lines": None, "problem": unread}
1749
+ args = ["mem", "extract", "--context", session, "--tools", "--after", start]
1750
+ command = " ".join(["atw", *args])
1751
+ atw = shutil.which("atw")
1752
+ if atw is None:
1753
+ return {"command": command, "lines": None, "problem": "PATH 上没有 atw"}
1754
+ try:
1755
+ proc = subprocess.run([atw, *args], stdin=subprocess.DEVNULL, capture_output=True, text=True,
1756
+ encoding="utf-8", errors="replace", timeout=MEM_TIMEOUT_S)
1757
+ except subprocess.TimeoutExpired:
1758
+ return {"command": command, "lines": None,
1759
+ "problem": f"`atw mem extract` {MEM_TIMEOUT_S:g} 秒内没跑完"}
1760
+ except OSError as e:
1761
+ return {"command": command, "lines": None, "problem": f"atw 起不来({e.strerror or e})"}
1762
+ if proc.returncode == 0:
1763
+ return {"command": command, "lines": len(proc.stdout.splitlines()), "problem": None}
1764
+ err = (proc.stderr.strip().splitlines() or [""])[-1]
1765
+ if proc.returncode == 2 and _MEM_NOT_FOUND in err:
1766
+ problem = f"编号 {session} 对不上任何会话"
1767
+ problem += ",多半是 ATW_CONTEXT_ID 手动设置的原样值" if _context_from_override(session) else f"({err})"
1768
+ else:
1769
+ problem = f"`atw mem extract` 退出 {proc.returncode}" + (f"({err})" if err else "")
1770
+ return {"command": command, "lines": None, "problem": problem}
1771
+
1772
+
1773
+ def this_run(rows: list[Row], session: str | None) -> tuple[list[Row], str | None, str]:
1774
+ """这次运行那一段:(各行, 对话编号, 怎么取的);取不到就抛 LogError。"""
1775
+ owner = owners(rows)
1776
+ no_session = {spec.no_session for spec in LANGS.values()}
1777
+ if session is None or session in no_session:
1778
+ # 没有编号的几次运行分不开:只认最后一段、且它的 start 行也没有编号,不拿别的对话充数
1779
+ starts = [i for i, r in enumerate(rows) if r.phase == START_PHASE]
1780
+ if not starts:
1781
+ raise LogError("日志里还没有 start 行")
1782
+ if rows[starts[-1]].evidence not in no_session:
1783
+ raise LogError(
1784
+ f"这次对话的编号查不到,而最后一条 start 行({rows[starts[-1]].raw_time})带着对话编号 "
1785
+ f"{rows[starts[-1]].evidence}:那一段不是没有编号的对话写的,不拿它充数"
1786
+ )
1787
+ picked = rows[starts[-1]:]
1788
+ session = None
1789
+ picked_by = "last-segment-without-session"
1790
+ elif session not in owner:
1791
+ raise LogError(f"日志里没有对话 {session} 的 start 行:这次对话还没写过日志")
1792
+ else:
1793
+ picked = [r for r, o in zip(rows, owner) if o == session]
1794
+ picked_by = "session"
1795
+ return picked, session, picked_by
1796
+
1797
+
1798
+ def cmd_run(path: Path, session: str | None, as_json: bool) -> int:
1799
+ existing = read_log(path)
1800
+ if existing is None:
1801
+ raise LogError("日志还没有:这个任务还没有决定日志")
1802
+ lang = LANGS[existing[0]]
1803
+ picked, session, picked_by = this_run(existing[1], session)
1804
+ evidence = evidence_pointers(picked)
1805
+ transcript = transcript_lines(session, picked[0].raw_time, lang)
1806
+ if as_json:
1807
+ print(json.dumps({
1808
+ "session": session,
1809
+ "picked_by": picked_by,
1810
+ "start": picked[0].raw_time,
1811
+ "end": picked[-1].raw_time,
1812
+ "rows": [row_json(r) for r in picked],
1813
+ "evidence": [
1814
+ {"time": r.raw_time, "phase": r.phase,
1815
+ "pointers": [{"text": p, "checkable": bool(pointer_keys(p))} for p in pointers]}
1816
+ for r, pointers in evidence
1817
+ ],
1818
+ "transcript": transcript,
1819
+ }, ensure_ascii=False, indent=2))
1820
+ return EXIT_OK
1821
+ if session is None:
1822
+ print("这次对话的编号查不到:按没有编号的最后一段取(最后一条 start 行的证据格也写着查不到)")
1823
+ print(f"对话编号:{session or '查不到'}")
1824
+ print(f"起:{picked[0].raw_time}")
1825
+ print(f"止:{picked[-1].raw_time}")
1826
+ print(f"共 {len(picked)} 行")
1827
+ if transcript["command"] is not None:
1828
+ print(f"聊天记录:`{transcript['command']}`")
1829
+ if transcript["lines"] is not None:
1830
+ print(f"聊天记录此刻共 {transcript['lines']} 行(参考数:自查行「读了聊天记录 X/N 行」的 N "
1831
+ "照你自己那次读取命令输出的总行数写)")
1832
+ else:
1833
+ what = "读不了" if transcript["command"] is None else "数不出行数"
1834
+ print(f"聊天记录{what}:{transcript['problem']}")
1835
+ # 数不出行数与读不了一样处理(prd J 节):只对照证据,原因写进自查行
1836
+ print(f"照读不了聊天记录处理:只对照证据,自查行结果格写「{lang.no_transcript}」")
1837
+ print(format_row(list(lang.header)))
1838
+ print(format_row(["---"] * 6))
1839
+ for r in picked:
1840
+ print(format_row([r.raw_time, r.phase, r.decision, r.why, r.evidence, r.result]))
1841
+ print()
1842
+ print("各行的证据指针(自查第 3 步照着逐个打开;自查时没打开的都列进「没打开:」):")
1843
+ for r, pointers in evidence:
1844
+ print(f"- {r.raw_time}({r.phase})")
1845
+ for p in pointers:
1846
+ note = "" if pointer_keys(p) else "(脚本认不出是哪个提交、文件或「等你处理」条目,写自查行时不替你对照)"
1847
+ print(f" - {p}{note}")
1848
+ if not pointers:
1849
+ print(" - (证据格是空的)")
1850
+ return EXIT_OK
1851
+
1852
+
1853
+ # ---------------------------------------------------------------------------
1854
+ # 入口
1855
+ # ---------------------------------------------------------------------------
1856
+
1857
+ def task_dir(root: Path, override: str | None) -> Path:
1858
+ """任务目录一律问 task.py,不自己解析 Session Pointer。"""
1859
+ if override:
1860
+ path = (root / override).resolve()
1861
+ if not path.is_dir():
1862
+ raise LogError(f"任务目录不存在:{override}")
1863
+ return path
1864
+ proc = subprocess.run(
1865
+ [sys.executable, str(root / DIR_WORKFLOW / DIR_SCRIPTS / "task.py"), "current", "--json"],
1866
+ capture_output=True, text=True, cwd=root,
1867
+ )
1868
+ rel = None
1869
+ if proc.returncode == 0:
1870
+ try:
1871
+ rel = (json.loads(proc.stdout).get("current_task") or {}).get("dir")
1872
+ except json.JSONDecodeError:
1873
+ rel = None
1874
+ if not rel:
1875
+ raise LogError("没有当前任务——决定日志要放在任务里;先建任务或用 --task-dir 指定")
1876
+ return (root / rel).resolve()
1877
+
1878
+
1879
+ def main(argv: list[str] | None = None) -> int:
1880
+ configure_encoding()
1881
+ p = argparse.ArgumentParser(description="ATW 决定日志:只往后加的六栏表")
1882
+ sub = p.add_subparsers(dest="command", required=True)
1883
+
1884
+ def common(sp: argparse.ArgumentParser) -> None:
1885
+ sp.add_argument("--task-dir", help="仓库相对的任务目录,缺省问 task.py current")
1886
+ sp.add_argument("--session", help="这次对话的编号,缺省取 ATW 的对话标识")
1887
+
1888
+ sp = sub.add_parser("start", help="写这次运行的 start 行")
1889
+ common(sp)
1890
+ sp.add_argument("--lang", choices=sorted(LANGS), help="日志还没有时的表头语言,之后沿用")
1891
+
1892
+ def row_fields(sp: argparse.ArgumentParser) -> None:
1893
+ common(sp)
1894
+ sp.add_argument("--phase", required=True, help="哪张工单、哪一步,如「03 审查」「开跑」")
1895
+ sp.add_argument("--decision", required=True)
1896
+ sp.add_argument("--why", required=True)
1897
+ sp.add_argument("--evidence", required=True, help="只写指针:提交号、文件:行、报告位置")
1898
+ sp.add_argument("--result", required=True)
1899
+ sp.add_argument("--mark", choices=["needs-you", "unsure"],
1900
+ help="needs-you = 待你确认;unsure = 拿不准")
1901
+
1902
+ row_fields(sub.add_parser("add", help="记一行"))
1903
+ sp = sub.add_parser("supersede", help="追加一行顶替某个时间的那一行")
1904
+ row_fields(sp)
1905
+ sp.add_argument("--time", required=True, help="被顶替那一行的时间")
1906
+ sp = sub.add_parser("ack", help="追加一行认可某个(或某几个)时间的行")
1907
+ row_fields(sp)
1908
+ sp.add_argument("--time", required=True, action="append", help="被认可那一行的时间,可给多次")
1909
+ sp = sub.add_parser("run", help="只读:取这次运行那一段的行与起止时间")
1910
+ common(sp)
1911
+ sp.add_argument("--json", action="store_true", help="给 JSON")
1912
+ sp = sub.add_parser("unsure", help="只读:列出没被顶替的「拿不准」行")
1913
+ common(sp)
1914
+ sp.add_argument("--json", action="store_true", help="给 JSON")
1915
+ sp = sub.add_parser("wait", help="记进「等你处理」一条,同时写对应的日志行")
1916
+ common(sp)
1917
+ sp.add_argument("--title", required=True, help="一句话标题")
1918
+ sp.add_argument("--ask", required=True, help="要用户做什么")
1919
+ sp.add_argument("--why", required=True, help="为什么卡在用户这儿;也是日志行的理由格")
1920
+ sp.add_argument("--options", required=True, help="有哪些选项")
1921
+ sp.add_argument("--default", required=True, help="没人回答时 AI 先按哪个做了")
1922
+ sp.add_argument("--if-ignored", default="", help="不处理会怎样,缺省写「不知道」")
1923
+ sp.add_argument("--prereq", default="", help="要先做什么,缺省写「不知道」")
1924
+ sp.add_argument("--irreversible", default="", help="哪一步撤不回,缺省写「不知道」")
1925
+ sp.add_argument("--placeholder", action="append", default=[], help="占位在哪,文件:行,可给多次")
1926
+ sp.add_argument("--command", dest="commands", action="append", default=[],
1927
+ help="用户回来后可以直接输入的命令,照原样,可给多次")
1928
+ sp.add_argument("--ticket", action="append", default=[], help="在等它的工单,可给多次")
1929
+ sp.add_argument("--category", required=True, choices=CATEGORIES,
1930
+ help="外部账号·密钥·付费 / 设计或产品选择 / 手动测试 / 法律决定 / "
1931
+ "留给你的一步(推送、合并、发布等红线上的一步) / 没解决的审查问题 / "
1932
+ "跳过的工单 / 日志复查没做成 / 其他")
1933
+ sp.add_argument("--phase", required=True, help="日志行的阶段,如「03 实现」")
1934
+ sp.add_argument("--evidence", required=True, help="日志行的证据,只写指针")
1935
+ sp = sub.add_parser("pending", help="只读:列出没处理的清单条目、还没被认可或顶替的两种标记行与日志复查里没处理的「要改」")
1936
+ common(sp)
1937
+ shape = sp.add_mutually_exclusive_group()
1938
+ shape.add_argument("--json", action="store_true", help="给 JSON")
1939
+ shape.add_argument("--report", action="store_true", help="给按四块排好的汇报底稿")
1940
+ sp = sub.add_parser("resolve", help="勾掉「等你处理」一条并写答复,同时写对应的日志行")
1941
+ common(sp)
1942
+ sp.add_argument("--item", required=True, type=int, help="清单条目的编号")
1943
+ sp.add_argument("--reply", required=True, help="用户的答复;也是日志行的理由格")
1944
+ sp.add_argument("--phase", required=True, help="日志行的阶段,如「验收」")
1945
+ sp.add_argument("--evidence", required=True, help="日志行的证据,只写指针")
1946
+ sp.add_argument("--result", help="日志行的结果格,缺省写「已处理」;核对过真东西时写核对结果")
1947
+ sp = sub.add_parser("standing", help="追加一条长期指令,同时写对应的日志行;只由主对话跑")
1948
+ common(sp)
1949
+ sp.add_argument("--order", required=True, help="长期指令,一句话;也是日志行的决定格")
1950
+ sp.add_argument("--why", required=True, help="日志行的理由:用户怎么说的,或发现自己在重复用户的话")
1951
+ sp.add_argument("--phase", required=True, help="日志行的阶段,如「03 实现」")
1952
+ sp.add_argument("--evidence", required=True, help="日志行的证据,只写指针")
1953
+ sp.add_argument("--lang", choices=sorted(LANGS), help="日志还没有时照 start 先开日志用的表头语言")
1954
+ args = p.parse_args(argv)
1955
+
1956
+ try:
1957
+ root = get_repo_root()
1958
+ task = task_dir(root, args.task_dir)
1959
+ path = task / LOG_NAME
1960
+ session = args.session or resolve_context_key()
1961
+ # 写命令从读文件到写完整段拿着这个任务的锁(prd D 节)
1962
+ with log_lock(root, task) if args.command in WRITE_COMMANDS else nullcontext():
1963
+ if args.command == "start":
1964
+ return cmd_start(path, args.lang, session)
1965
+ if args.command == "run":
1966
+ return cmd_run(path, session, args.json)
1967
+ if args.command == "unsure":
1968
+ return cmd_unsure(path, args.json)
1969
+ if args.command == "wait":
1970
+ item: dict[str, object] = {
1971
+ key: one_line(getattr(args, key))
1972
+ for key in ("ask", "why", "options", "default", "if_ignored", "prereq", "irreversible")
1973
+ }
1974
+ item["placeholders"] = [one_line(v) for v in args.placeholder]
1975
+ item["commands"] = [host_form(one_line(v)) for v in args.commands]
1976
+ item["tickets"] = [one_line(v) for v in args.ticket]
1977
+ item["category"] = args.category
1978
+ return cmd_wait(path, path.with_name(WAIT_NAME), session, one_line(args.title),
1979
+ item, args.phase, args.evidence)
1980
+ if args.command == "pending":
1981
+ return cmd_pending(path, path.with_name(WAIT_NAME), args.json, args.report, is_noob_mode_on(root))
1982
+ if args.command == "resolve":
1983
+ return cmd_resolve(path, path.with_name(WAIT_NAME), session, args.item, args.reply,
1984
+ args.phase, args.evidence, args.result)
1985
+ if args.command == "standing":
1986
+ return cmd_standing(path, path.with_name(STANDING_NAME), session, args.order, args.why,
1987
+ args.phase, args.evidence, args.lang)
1988
+ fields = [args.phase, args.decision, args.why, args.evidence, args.result]
1989
+ if args.command == "add":
1990
+ return cmd_add(path, session, fields, args.mark)
1991
+ if args.command == "supersede":
1992
+ return cmd_add(path, session, fields, args.mark, "supersede", [args.time])
1993
+ if args.command == "ack":
1994
+ return cmd_add(path, session, fields, args.mark, "ack", args.time)
1995
+ except LogError as e:
1996
+ print(f"错误:{e}", file=sys.stderr)
1997
+ return EXIT_ERROR
1998
+ return EXIT_ERROR
1999
+
2000
+
2001
+ if __name__ == "__main__":
2002
+ sys.exit(main())