dsh-compound-engineering 0.0.0-stage → 0.1.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 (461) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +160 -2
  3. package/README.zh.md +161 -0
  4. package/cordis.patch.yml +6 -0
  5. package/lib/frontmatter.js +167 -0
  6. package/lib/index.js +46 -0
  7. package/lib/provider.js +201 -0
  8. package/package.json +62 -4
  9. package/skills/CODING_STANDARDS.md +68 -0
  10. package/skills/ce-babysit-pr/SKILL.md +59 -0
  11. package/skills/ce-babysit-pr/references/branch-currency.md +22 -0
  12. package/skills/ce-babysit-pr/references/envelope.md +44 -0
  13. package/skills/ce-babysit-pr/references/pipeline.md +15 -0
  14. package/skills/ce-babysit-pr/references/report.md +20 -0
  15. package/skills/ce-babysit-pr/references/settle.md +37 -0
  16. package/skills/ce-babysit-pr/references/setup.md +33 -0
  17. package/skills/ce-babysit-pr/references/stack-commands.md +39 -0
  18. package/skills/ce-babysit-pr/references/stack.md +43 -0
  19. package/skills/ce-babysit-pr/references/tick.md +102 -0
  20. package/skills/ce-babysit-pr/references/watch-loop.md +222 -0
  21. package/skills/ce-babysit-pr/scripts/pr-snapshot +3656 -0
  22. package/skills/ce-bakeoff/SKILL.md +48 -0
  23. package/skills/ce-bakeoff/references/candidates.md +37 -0
  24. package/skills/ce-bakeoff/references/judging.md +19 -0
  25. package/skills/ce-bakeoff/references/output.md +11 -0
  26. package/skills/ce-bakeoff/references/verification.md +15 -0
  27. package/skills/ce-brainstorm/SKILL.md +69 -0
  28. package/skills/ce-brainstorm/references/agents/slack-researcher.md +127 -0
  29. package/skills/ce-brainstorm/references/approaches.md +57 -0
  30. package/skills/ce-brainstorm/references/bakeoff.md +9 -0
  31. package/skills/ce-brainstorm/references/blindspot-pass.md +70 -0
  32. package/skills/ce-brainstorm/references/brainstorm-sections.md +458 -0
  33. package/skills/ce-brainstorm/references/dialogue.md +89 -0
  34. package/skills/ce-brainstorm/references/handoff.md +187 -0
  35. package/skills/ce-brainstorm/references/html-rendering.md +638 -0
  36. package/skills/ce-brainstorm/references/interaction-rules.md +33 -0
  37. package/skills/ce-brainstorm/references/markdown-rendering.md +242 -0
  38. package/skills/ce-brainstorm/references/model-tiers.md +13 -0
  39. package/skills/ce-brainstorm/references/output-mode.md +26 -0
  40. package/skills/ce-brainstorm/references/phase-0.md +104 -0
  41. package/skills/ce-brainstorm/references/plan-write.md +32 -0
  42. package/skills/ce-brainstorm/references/product-pressure-test.md +42 -0
  43. package/skills/ce-brainstorm/references/reasoning-elevation.md +124 -0
  44. package/skills/ce-brainstorm/references/settled-decisions.md +45 -0
  45. package/skills/ce-brainstorm/references/synthesis-summary.md +149 -0
  46. package/skills/ce-brainstorm/references/universal-brainstorming.md +72 -0
  47. package/skills/ce-brainstorm/references/verdict-routing.md +26 -0
  48. package/skills/ce-brainstorm/references/visual-probes.md +164 -0
  49. package/skills/ce-brainstorm/scripts/elevation-dispatch.sh +288 -0
  50. package/skills/ce-brainstorm/scripts/light-webserver.js +1275 -0
  51. package/skills/ce-brainstorm/scripts/packs-resolve.py +737 -0
  52. package/skills/ce-brainstorm/scripts/peer-job-runner.py +2594 -0
  53. package/skills/ce-code-review/SKILL.md +40 -0
  54. package/skills/ce-code-review/references/action-class-rubric.md +67 -0
  55. package/skills/ce-code-review/references/cross-model-recovery.md +38 -0
  56. package/skills/ce-code-review/references/cross-model-review.md +191 -0
  57. package/skills/ce-code-review/references/depth-paths.md +32 -0
  58. package/skills/ce-code-review/references/diff-scope.md +59 -0
  59. package/skills/ce-code-review/references/dispatch-reviewers.md +135 -0
  60. package/skills/ce-code-review/references/findings-schema.json +125 -0
  61. package/skills/ce-code-review/references/finish-input.md +118 -0
  62. package/skills/ce-code-review/references/finish-review.md +202 -0
  63. package/skills/ce-code-review/references/intent-and-plan.md +73 -0
  64. package/skills/ce-code-review/references/modes-and-output.md +156 -0
  65. package/skills/ce-code-review/references/persona-catalog.md +66 -0
  66. package/skills/ce-code-review/references/personas/adversarial-reviewer.md +111 -0
  67. package/skills/ce-code-review/references/personas/agent-native-reviewer.md +173 -0
  68. package/skills/ce-code-review/references/personas/api-contract-reviewer.md +44 -0
  69. package/skills/ce-code-review/references/personas/correctness-reviewer.md +48 -0
  70. package/skills/ce-code-review/references/personas/data-migration-reviewer.md +111 -0
  71. package/skills/ce-code-review/references/personas/deployment-verification-agent.md +157 -0
  72. package/skills/ce-code-review/references/personas/julik-frontend-races-reviewer.md +45 -0
  73. package/skills/ce-code-review/references/personas/learnings-researcher.md +260 -0
  74. package/skills/ce-code-review/references/personas/maintainability-reviewer.md +85 -0
  75. package/skills/ce-code-review/references/personas/performance-reviewer.md +45 -0
  76. package/skills/ce-code-review/references/personas/previous-comments-reviewer.md +59 -0
  77. package/skills/ce-code-review/references/personas/project-standards-reviewer.md +77 -0
  78. package/skills/ce-code-review/references/personas/reliability-reviewer.md +49 -0
  79. package/skills/ce-code-review/references/personas/security-reviewer.md +50 -0
  80. package/skills/ce-code-review/references/personas/swift-ios-reviewer.md +99 -0
  81. package/skills/ce-code-review/references/personas/testing-reviewer.md +51 -0
  82. package/skills/ce-code-review/references/review-output-template.md +172 -0
  83. package/skills/ce-code-review/references/scope.md +176 -0
  84. package/skills/ce-code-review/references/select-and-route.md +93 -0
  85. package/skills/ce-code-review/references/subagent-template.md +206 -0
  86. package/skills/ce-code-review/references/validator-batch-template.md +67 -0
  87. package/skills/ce-code-review/scripts/cross-model-adversarial-review.sh +1275 -0
  88. package/skills/ce-code-review/scripts/findings-mechanics.py +274 -0
  89. package/skills/ce-code-review/scripts/packs-resolve.py +737 -0
  90. package/skills/ce-code-review/scripts/peer-job-runner.py +2594 -0
  91. package/skills/ce-code-review/scripts/review-scope.py +419 -0
  92. package/skills/ce-code-review/scripts/run-log.py +280 -0
  93. package/skills/ce-commit/SKILL.md +60 -0
  94. package/skills/ce-commit-push-pr/SKILL.md +69 -0
  95. package/skills/ce-commit-push-pr/references/apply-and-handoff.md +79 -0
  96. package/skills/ce-commit-push-pr/references/branch-creation.md +47 -0
  97. package/skills/ce-commit-push-pr/references/commit-and-push.md +26 -0
  98. package/skills/ce-commit-push-pr/references/compose.md +29 -0
  99. package/skills/ce-commit-push-pr/references/context.md +42 -0
  100. package/skills/ce-commit-push-pr/references/gh-stack-cli.md +83 -0
  101. package/skills/ce-commit-push-pr/references/pr-description-writing.md +208 -0
  102. package/skills/ce-commit-push-pr/references/stack-submit.md +81 -0
  103. package/skills/ce-compound/SKILL.md +79 -0
  104. package/skills/ce-compound/assets/resolution-template.md +96 -0
  105. package/skills/ce-compound/references/agents/best-practices-researcher.md +115 -0
  106. package/skills/ce-compound/references/agents/data-integrity-guardian.md +68 -0
  107. package/skills/ce-compound/references/agents/framework-docs-researcher.md +93 -0
  108. package/skills/ce-compound/references/agents/pattern-recognition-specialist.md +55 -0
  109. package/skills/ce-compound/references/agents/performance-oracle.md +108 -0
  110. package/skills/ce-compound/references/agents/security-sentinel.md +91 -0
  111. package/skills/ce-compound/references/agents/session-historian.md +82 -0
  112. package/skills/ce-compound/references/assembly.md +127 -0
  113. package/skills/ce-compound/references/concepts-vocabulary.md +98 -0
  114. package/skills/ce-compound/references/enhancement.md +42 -0
  115. package/skills/ce-compound/references/grounding-validation.md +89 -0
  116. package/skills/ce-compound/references/lightweight.md +56 -0
  117. package/skills/ce-compound/references/modes.md +13 -0
  118. package/skills/ce-compound/references/refresh-and-discoverability.md +96 -0
  119. package/skills/ce-compound/references/report.md +115 -0
  120. package/skills/ce-compound/references/research.md +144 -0
  121. package/skills/ce-compound/references/schema.yaml +236 -0
  122. package/skills/ce-compound/references/session-history.md +73 -0
  123. package/skills/ce-compound/references/yaml-schema.md +130 -0
  124. package/skills/ce-compound/scripts/packs-resolve.py +737 -0
  125. package/skills/ce-compound/scripts/session-history/__pycache__/dsh_session.cpython-314.pyc +0 -0
  126. package/skills/ce-compound/scripts/session-history/discover-sessions.sh +290 -0
  127. package/skills/ce-compound/scripts/session-history/discover_dsh.py +123 -0
  128. package/skills/ce-compound/scripts/session-history/dsh_session.py +225 -0
  129. package/skills/ce-compound/scripts/session-history/extract-errors.py +178 -0
  130. package/skills/ce-compound/scripts/session-history/extract-metadata.py +301 -0
  131. package/skills/ce-compound/scripts/session-history/extract-skeleton.py +346 -0
  132. package/skills/ce-compound/scripts/validate-doc-claims.py +497 -0
  133. package/skills/ce-compound/scripts/validate-frontmatter.py +137 -0
  134. package/skills/ce-compound-refresh/SKILL.md +88 -0
  135. package/skills/ce-compound-refresh/assets/resolution-template.md +96 -0
  136. package/skills/ce-compound-refresh/references/classify.md +42 -0
  137. package/skills/ce-compound-refresh/references/commit.md +7 -0
  138. package/skills/ce-compound-refresh/references/concepts-vocabulary.md +116 -0
  139. package/skills/ce-compound-refresh/references/discoverability.md +15 -0
  140. package/skills/ce-compound-refresh/references/investigate.md +23 -0
  141. package/skills/ce-compound-refresh/references/modes.md +27 -0
  142. package/skills/ce-compound-refresh/references/per-action-flows.md +142 -0
  143. package/skills/ce-compound-refresh/references/report.md +23 -0
  144. package/skills/ce-compound-refresh/references/schema.yaml +236 -0
  145. package/skills/ce-compound-refresh/references/scope.md +18 -0
  146. package/skills/ce-compound-refresh/references/worth-audit.md +35 -0
  147. package/skills/ce-compound-refresh/references/yaml-schema.md +130 -0
  148. package/skills/ce-compound-refresh/scripts/validate-doc-claims.py +497 -0
  149. package/skills/ce-compound-refresh/scripts/validate-frontmatter.py +137 -0
  150. package/skills/ce-debug/SKILL.md +122 -0
  151. package/skills/ce-debug/references/anti-patterns.md +91 -0
  152. package/skills/ce-debug/references/defense-in-depth.md +39 -0
  153. package/skills/ce-debug/references/fix.md +21 -0
  154. package/skills/ce-debug/references/investigate.md +114 -0
  155. package/skills/ce-debug/references/investigation-techniques.md +384 -0
  156. package/skills/ce-debug/references/pipeline-mode.md +78 -0
  157. package/skills/ce-debug/references/post-fix-handoff.md +60 -0
  158. package/skills/ce-debug/references/return-to-caller.md +57 -0
  159. package/skills/ce-doc-review/SKILL.md +75 -0
  160. package/skills/ce-doc-review/references/bulk-preview.md +146 -0
  161. package/skills/ce-doc-review/references/cross-model-review.md +207 -0
  162. package/skills/ce-doc-review/references/decision-primer.md +47 -0
  163. package/skills/ce-doc-review/references/dispatch.md +56 -0
  164. package/skills/ce-doc-review/references/document-intake.md +44 -0
  165. package/skills/ce-doc-review/references/findings-schema.json +85 -0
  166. package/skills/ce-doc-review/references/modes.md +20 -0
  167. package/skills/ce-doc-review/references/open-questions-defer.md +158 -0
  168. package/skills/ce-doc-review/references/persona-selection.md +41 -0
  169. package/skills/ce-doc-review/references/personas/adversarial-document-reviewer.md +109 -0
  170. package/skills/ce-doc-review/references/personas/coherence-reviewer.md +69 -0
  171. package/skills/ce-doc-review/references/personas/design-lens-reviewer.md +48 -0
  172. package/skills/ce-doc-review/references/personas/feasibility-reviewer.md +50 -0
  173. package/skills/ce-doc-review/references/personas/product-lens-reviewer.md +85 -0
  174. package/skills/ce-doc-review/references/personas/scope-guardian-reviewer.md +83 -0
  175. package/skills/ce-doc-review/references/personas/security-lens-reviewer.md +41 -0
  176. package/skills/ce-doc-review/references/personas/whole-doc-reviewer.md +25 -0
  177. package/skills/ce-doc-review/references/rendering-floor.md +175 -0
  178. package/skills/ce-doc-review/references/review-output-template.md +123 -0
  179. package/skills/ce-doc-review/references/subagent-template.md +188 -0
  180. package/skills/ce-doc-review/references/synthesis-and-presentation.md +350 -0
  181. package/skills/ce-doc-review/references/walkthrough.md +347 -0
  182. package/skills/ce-doc-review/scripts/cross-model-doc-review.sh +1270 -0
  183. package/skills/ce-doc-review/scripts/packs-resolve.py +737 -0
  184. package/skills/ce-doc-review/scripts/peer-job-runner.py +2594 -0
  185. package/skills/ce-dogfood/SKILL.md +66 -0
  186. package/skills/ce-dogfood/agents/openai.yaml +2 -0
  187. package/skills/ce-dogfood/references/dogfood-report-template.md +97 -0
  188. package/skills/ce-dogfood/references/phases.md +167 -0
  189. package/skills/ce-dogfood/references/test-matrix-taxonomy.md +62 -0
  190. package/skills/ce-dogfood/scripts/packs-resolve.py +737 -0
  191. package/skills/ce-explain/SKILL.md +81 -0
  192. package/skills/ce-explain/references/agents/behavior-trace-scout.md +19 -0
  193. package/skills/ce-explain/references/agents/work-recap-scout.md +23 -0
  194. package/skills/ce-explain/references/check-in.md +18 -0
  195. package/skills/ce-explain/references/destinations.md +35 -0
  196. package/skills/ce-explain/references/explainer-html.md +29 -0
  197. package/skills/ce-explain/references/explainer-markdown.md +24 -0
  198. package/skills/ce-explain/references/intake.md +50 -0
  199. package/skills/ce-explain/references/orchestration.md +46 -0
  200. package/skills/ce-handoff/SKILL.md +54 -0
  201. package/skills/ce-handoff/references/create.md +88 -0
  202. package/skills/ce-handoff/references/resume.md +46 -0
  203. package/skills/ce-ideate/SKILL.md +75 -0
  204. package/skills/ce-ideate/references/agents/issue-intelligence-analyst.md +166 -0
  205. package/skills/ce-ideate/references/agents/learnings-researcher.md +247 -0
  206. package/skills/ce-ideate/references/agents/slack-researcher.md +127 -0
  207. package/skills/ce-ideate/references/agents/web-researcher.md +121 -0
  208. package/skills/ce-ideate/references/decomposition.md +39 -0
  209. package/skills/ce-ideate/references/divergent-ideation.md +112 -0
  210. package/skills/ce-ideate/references/grounding.md +138 -0
  211. package/skills/ce-ideate/references/html-rendering.md +638 -0
  212. package/skills/ce-ideate/references/ideation-sections.md +191 -0
  213. package/skills/ce-ideate/references/issue-intelligence.md +44 -0
  214. package/skills/ce-ideate/references/markdown-rendering.md +242 -0
  215. package/skills/ce-ideate/references/output-mode.md +34 -0
  216. package/skills/ce-ideate/references/post-ideation-workflow.md +169 -0
  217. package/skills/ce-ideate/references/scope-gates.md +148 -0
  218. package/skills/ce-ideate/references/universal-ideation.md +119 -0
  219. package/skills/ce-ideate/references/user-research-artifacts.md +28 -0
  220. package/skills/ce-ideate/references/web-research-cache.md +56 -0
  221. package/skills/ce-noslop/SKILL.md +45 -0
  222. package/skills/ce-noslop/references/patterns.md +61 -0
  223. package/skills/ce-noslop/references/terminology.md +3 -0
  224. package/skills/ce-optimize/SKILL.md +64 -0
  225. package/skills/ce-optimize/references/agents/learnings-researcher.md +247 -0
  226. package/skills/ce-optimize/references/agents/repo-research-analyst.md +258 -0
  227. package/skills/ce-optimize/references/example-expensive-benchmark-spec.yaml +88 -0
  228. package/skills/ce-optimize/references/example-hard-spec.yaml +64 -0
  229. package/skills/ce-optimize/references/example-judge-spec.yaml +83 -0
  230. package/skills/ce-optimize/references/example-text-target-spec.yaml +80 -0
  231. package/skills/ce-optimize/references/experiment-log-schema.yaml +563 -0
  232. package/skills/ce-optimize/references/experiment-prompt-template.md +143 -0
  233. package/skills/ce-optimize/references/judge-prompt-template.md +117 -0
  234. package/skills/ce-optimize/references/loop.md +286 -0
  235. package/skills/ce-optimize/references/measurement.md +168 -0
  236. package/skills/ce-optimize/references/optimize-spec-schema.yaml +570 -0
  237. package/skills/ce-optimize/references/persistence.md +93 -0
  238. package/skills/ce-optimize/references/spec.md +107 -0
  239. package/skills/ce-optimize/references/text-targets.md +51 -0
  240. package/skills/ce-optimize/references/usage-guide.md +147 -0
  241. package/skills/ce-optimize/references/wrap-up.md +54 -0
  242. package/skills/ce-optimize/scripts/decide.mjs +676 -0
  243. package/skills/ce-optimize/scripts/experiment-worktree.sh +330 -0
  244. package/skills/ce-optimize/scripts/measure.sh +140 -0
  245. package/skills/ce-optimize/scripts/parallel-probe.sh +134 -0
  246. package/skills/ce-plan/SKILL.md +60 -0
  247. package/skills/ce-plan/references/agents/agent-native-planning-strategist.md +66 -0
  248. package/skills/ce-plan/references/agents/architecture-strategist.md +46 -0
  249. package/skills/ce-plan/references/agents/best-practices-researcher.md +118 -0
  250. package/skills/ce-plan/references/agents/data-integrity-guardian.md +68 -0
  251. package/skills/ce-plan/references/agents/framework-docs-researcher.md +97 -0
  252. package/skills/ce-plan/references/agents/git-history-analyzer.md +40 -0
  253. package/skills/ce-plan/references/agents/learnings-researcher.md +268 -0
  254. package/skills/ce-plan/references/agents/pattern-recognition-specialist.md +55 -0
  255. package/skills/ce-plan/references/agents/performance-oracle.md +108 -0
  256. package/skills/ce-plan/references/agents/repo-research-analyst.md +262 -0
  257. package/skills/ce-plan/references/agents/security-sentinel.md +91 -0
  258. package/skills/ce-plan/references/agents/slack-researcher.md +131 -0
  259. package/skills/ce-plan/references/agents/spec-flow-analyzer.md +84 -0
  260. package/skills/ce-plan/references/agents/web-researcher.md +125 -0
  261. package/skills/ce-plan/references/approach-altitude.md +55 -0
  262. package/skills/ce-plan/references/bakeoff.md +9 -0
  263. package/skills/ce-plan/references/deepening-workflow.md +263 -0
  264. package/skills/ce-plan/references/final-review.md +134 -0
  265. package/skills/ce-plan/references/html-rendering.md +638 -0
  266. package/skills/ce-plan/references/intake.md +158 -0
  267. package/skills/ce-plan/references/markdown-rendering.md +242 -0
  268. package/skills/ce-plan/references/output-contracts.md +29 -0
  269. package/skills/ce-plan/references/output-mode.md +45 -0
  270. package/skills/ce-plan/references/plan-handoff.md +116 -0
  271. package/skills/ce-plan/references/plan-sections.md +489 -0
  272. package/skills/ce-plan/references/reasoning-elevation.md +124 -0
  273. package/skills/ce-plan/references/research.md +211 -0
  274. package/skills/ce-plan/references/resume.md +61 -0
  275. package/skills/ce-plan/references/settled-decisions.md +45 -0
  276. package/skills/ce-plan/references/structure.md +207 -0
  277. package/skills/ce-plan/references/synthesis-summary.md +260 -0
  278. package/skills/ce-plan/references/universal-planning.md +170 -0
  279. package/skills/ce-plan/scripts/elevation-dispatch.sh +288 -0
  280. package/skills/ce-plan/scripts/packs-resolve.py +737 -0
  281. package/skills/ce-plan/scripts/peer-job-runner.py +2594 -0
  282. package/skills/ce-polish/SKILL.md +22 -0
  283. package/skills/ce-polish/agents/openai.yaml +2 -0
  284. package/skills/ce-polish/references/dev-server-astro.md +58 -0
  285. package/skills/ce-polish/references/dev-server-detection.md +40 -0
  286. package/skills/ce-polish/references/dev-server-next.md +61 -0
  287. package/skills/ce-polish/references/dev-server-nuxt.md +58 -0
  288. package/skills/ce-polish/references/dev-server-procfile.md +59 -0
  289. package/skills/ce-polish/references/dev-server-rails.md +50 -0
  290. package/skills/ce-polish/references/dev-server-remix.md +58 -0
  291. package/skills/ce-polish/references/dev-server-sveltekit.md +58 -0
  292. package/skills/ce-polish/references/dev-server-vite.md +48 -0
  293. package/skills/ce-polish/references/install-riffrec.md +35 -0
  294. package/skills/ce-polish/references/launch-json-schema.md +177 -0
  295. package/skills/ce-polish/references/live-loop.md +98 -0
  296. package/skills/ce-polish/references/live-remote.md +35 -0
  297. package/skills/ce-polish/references/live-start.md +93 -0
  298. package/skills/ce-polish/references/live-stream-contract.md +253 -0
  299. package/skills/ce-polish/references/run.md +67 -0
  300. package/skills/ce-polish/scripts/detect-project-type.sh +277 -0
  301. package/skills/ce-polish/scripts/detect-riffrec.sh +225 -0
  302. package/skills/ce-polish/scripts/live-endpoint.js +2841 -0
  303. package/skills/ce-polish/scripts/read-launch-json.sh +117 -0
  304. package/skills/ce-polish/scripts/resolve-package-manager.sh +95 -0
  305. package/skills/ce-polish/scripts/resolve-port.sh +307 -0
  306. package/skills/ce-pov/SKILL.md +64 -0
  307. package/skills/ce-pov/references/agents/external-evidence-researcher.md +26 -0
  308. package/skills/ce-pov/references/agents/pov-peer.md +43 -0
  309. package/skills/ce-pov/references/agents/precedent-activity-scout.md +21 -0
  310. package/skills/ce-pov/references/agents/project-grounding-scout.md +32 -0
  311. package/skills/ce-pov/references/boundaries.md +36 -0
  312. package/skills/ce-pov/references/cross-model-panel.md +596 -0
  313. package/skills/ce-pov/references/followup.md +9 -0
  314. package/skills/ce-pov/references/grounding.md +47 -0
  315. package/skills/ce-pov/references/intake.md +61 -0
  316. package/skills/ce-pov/references/invocation.md +47 -0
  317. package/skills/ce-pov/references/method.md +68 -0
  318. package/skills/ce-pov/references/pov-schema.json +56 -0
  319. package/skills/ce-pov/references/report.md +7 -0
  320. package/skills/ce-pov/scripts/cross-model-pov.sh +1110 -0
  321. package/skills/ce-pov/scripts/peer-job-runner.py +2594 -0
  322. package/skills/ce-product-pulse/SKILL.md +81 -0
  323. package/skills/ce-product-pulse/agents/openai.yaml +2 -0
  324. package/skills/ce-product-pulse/references/config.md +20 -0
  325. package/skills/ce-product-pulse/references/interview.md +260 -0
  326. package/skills/ce-product-pulse/references/report-template.md +89 -0
  327. package/skills/ce-product-pulse/references/run.md +46 -0
  328. package/skills/ce-product-pulse/references/setup.md +43 -0
  329. package/skills/ce-promote/SKILL.md +90 -0
  330. package/skills/ce-promote/agents/openai.yaml +2 -0
  331. package/skills/ce-promote/references/spiral-cli.md +149 -0
  332. package/skills/ce-proof/SKILL.md +65 -0
  333. package/skills/ce-proof/references/api.md +176 -0
  334. package/skills/ce-proof/references/workflows.md +124 -0
  335. package/skills/ce-prototype/SKILL.md +59 -0
  336. package/skills/ce-prototype/assets/annotate.css +206 -0
  337. package/skills/ce-prototype/assets/annotate.js +837 -0
  338. package/skills/ce-prototype/references/annotation-loop.md +32 -0
  339. package/skills/ce-prototype/references/build.md +39 -0
  340. package/skills/ce-prototype/references/craft-floor.md +43 -0
  341. package/skills/ce-prototype/references/preview.md +118 -0
  342. package/skills/ce-prototype/references/scoping.md +38 -0
  343. package/skills/ce-prototype/references/write-back.md +37 -0
  344. package/skills/ce-prototype/scripts/light-webserver.js +1275 -0
  345. package/skills/ce-resolve-pr-feedback/SKILL.md +60 -0
  346. package/skills/ce-resolve-pr-feedback/references/agents/pr-comment-resolver.md +59 -0
  347. package/skills/ce-resolve-pr-feedback/references/evaluation-rubric.md +141 -0
  348. package/skills/ce-resolve-pr-feedback/references/full-mode.md +336 -0
  349. package/skills/ce-resolve-pr-feedback/references/pipeline-mode.md +23 -0
  350. package/skills/ce-resolve-pr-feedback/references/resume.md +60 -0
  351. package/skills/ce-resolve-pr-feedback/references/return-to-caller.md +111 -0
  352. package/skills/ce-resolve-pr-feedback/references/targeted-mode.md +48 -0
  353. package/skills/ce-resolve-pr-feedback/scripts/get-pr-comments +176 -0
  354. package/skills/ce-resolve-pr-feedback/scripts/get-thread-for-comment +84 -0
  355. package/skills/ce-resolve-pr-feedback/scripts/pending-feedback.py +287 -0
  356. package/skills/ce-resolve-pr-feedback/scripts/reply-to-pr-thread +57 -0
  357. package/skills/ce-resolve-pr-feedback/scripts/resolve-pr-thread +23 -0
  358. package/skills/ce-retune/SKILL.md +42 -0
  359. package/skills/ce-retune/agents/openai.yaml +2 -0
  360. package/skills/ce-retune/references/baseline-mining.md +122 -0
  361. package/skills/ce-retune/references/corpus-audit.md +128 -0
  362. package/skills/ce-retune/references/cut-passes.md +129 -0
  363. package/skills/ce-retune/references/halt-taxonomy.md +123 -0
  364. package/skills/ce-retune/references/noise-floor.md +131 -0
  365. package/skills/ce-retune/references/workflow-shapes.md +73 -0
  366. package/skills/ce-riffrec-feedback-analysis/SKILL.md +33 -0
  367. package/skills/ce-riffrec-feedback-analysis/references/analyzer.md +18 -0
  368. package/skills/ce-riffrec-feedback-analysis/references/compound-engineering-feedback-format.md +116 -0
  369. package/skills/ce-riffrec-feedback-analysis/references/extensive-analysis.md +98 -0
  370. package/skills/ce-riffrec-feedback-analysis/references/install-riffrec.md +27 -0
  371. package/skills/ce-riffrec-feedback-analysis/references/quick-bug-report.md +38 -0
  372. package/skills/ce-riffrec-feedback-analysis/scripts/analyze_riffrec_zip.py +1306 -0
  373. package/skills/ce-setup/SKILL.md +103 -0
  374. package/skills/ce-setup/agents/openai.yaml +2 -0
  375. package/skills/ce-setup/assets/compounding-directive.md +11 -0
  376. package/skills/ce-setup/assets/noslop-directive.md +7 -0
  377. package/skills/ce-setup/assets/pack-rule-template.md +11 -0
  378. package/skills/ce-setup/references/acpx-cache-warm.md +14 -0
  379. package/skills/ce-setup/references/config-template.yaml +210 -0
  380. package/skills/ce-setup/references/legacy-codex-tool-map.md +15 -0
  381. package/skills/ce-setup/references/pack-scaffold.md +45 -0
  382. package/skills/ce-setup/references/repo-fixes.md +121 -0
  383. package/skills/ce-setup/scripts/check-health +929 -0
  384. package/skills/ce-setup/scripts/packs-resolve.py +737 -0
  385. package/skills/ce-simplify-code/SKILL.md +76 -0
  386. package/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
  387. package/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
  388. package/skills/ce-simplify-code/references/personas/comment-reviewer.md +24 -0
  389. package/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
  390. package/skills/ce-simplify-code/references/personas/test-value-reviewer.md +23 -0
  391. package/skills/ce-simplify-code/references/reviewer-dispatch.md +11 -0
  392. package/skills/ce-simplify-code/references/standalone-report.md +25 -0
  393. package/skills/ce-strategy/SKILL.md +61 -0
  394. package/skills/ce-strategy/references/grounding.md +22 -0
  395. package/skills/ce-strategy/references/interview.md +179 -0
  396. package/skills/ce-strategy/references/strategy-template.md +94 -0
  397. package/skills/ce-strategy/references/update-run.md +33 -0
  398. package/skills/ce-sweep/SKILL.md +93 -0
  399. package/skills/ce-sweep/agents/openai.yaml +2 -0
  400. package/skills/ce-sweep/references/agents/media-analyzer.md +54 -0
  401. package/skills/ce-sweep/references/interview.md +208 -0
  402. package/skills/ce-sweep/references/model-tiers.md +13 -0
  403. package/skills/ce-sweep/references/plan-template.md +62 -0
  404. package/skills/ce-sweep/references/run.md +126 -0
  405. package/skills/ce-sweep/references/sources/email.md +57 -0
  406. package/skills/ce-sweep/references/sources/github-issues.md +59 -0
  407. package/skills/ce-sweep/references/sources/slack.md +61 -0
  408. package/skills/ce-sweep/references/state-schema.md +157 -0
  409. package/skills/ce-sweep/references/subagent-template.md +60 -0
  410. package/skills/ce-sweep/scripts/analyze_riffrec_zip.py +1306 -0
  411. package/skills/ce-sweep/scripts/sweep-state.py +793 -0
  412. package/skills/ce-test-browser/SKILL.md +50 -0
  413. package/skills/ce-test-browser/references/agent-browser-driver.md +47 -0
  414. package/skills/ce-test-browser/references/pipeline-orchestration.md +45 -0
  415. package/skills/ce-test-browser/references/route-and-report.md +129 -0
  416. package/skills/ce-test-browser/scripts/resolve-port.sh +41 -0
  417. package/skills/ce-test-xcode/SKILL.md +26 -0
  418. package/skills/ce-test-xcode/agents/openai.yaml +2 -0
  419. package/skills/ce-test-xcode/references/setup-and-build.md +32 -0
  420. package/skills/ce-test-xcode/references/test-and-report.md +64 -0
  421. package/skills/ce-work/SKILL.md +62 -0
  422. package/skills/ce-work/references/agents/figma-design-sync.md +27 -0
  423. package/skills/ce-work/references/agents/implementation-worker.md +17 -0
  424. package/skills/ce-work/references/cross-model-execution.md +191 -0
  425. package/skills/ce-work/references/execution-engines.md +64 -0
  426. package/skills/ce-work/references/execution-strategy.md +73 -0
  427. package/skills/ce-work/references/implementation-loop.md +107 -0
  428. package/skills/ce-work/references/implementation-result-schema.json +52 -0
  429. package/skills/ce-work/references/input-triage.md +50 -0
  430. package/skills/ce-work/references/non-code-execution.md +23 -0
  431. package/skills/ce-work/references/return-to-caller.md +33 -0
  432. package/skills/ce-work/references/review-findings-followup.md +82 -0
  433. package/skills/ce-work/references/shipping-workflow.md +89 -0
  434. package/skills/ce-work/references/tracker-defer.md +150 -0
  435. package/skills/ce-work/references/work-intake.md +51 -0
  436. package/skills/ce-work/references/workspace-setup.md +27 -0
  437. package/skills/ce-work/scripts/__pycache__/unit_workspace_jobs.cpython-314.pyc +0 -0
  438. package/skills/ce-work/scripts/__pycache__/unit_workspace_state.cpython-314.pyc +0 -0
  439. package/skills/ce-work/scripts/cross-model-work.sh +1385 -0
  440. package/skills/ce-work/scripts/peer-job-runner.py +2594 -0
  441. package/skills/ce-work/scripts/unit-workspace.py +215 -0
  442. package/skills/ce-work/scripts/unit_workspace_ignored.py +93 -0
  443. package/skills/ce-work/scripts/unit_workspace_integration.py +677 -0
  444. package/skills/ce-work/scripts/unit_workspace_jobs.py +1216 -0
  445. package/skills/ce-work/scripts/unit_workspace_lifecycle.py +981 -0
  446. package/skills/ce-work/scripts/unit_workspace_state.py +1379 -0
  447. package/skills/ce-work/scripts/unit_workspace_transaction.py +612 -0
  448. package/skills/ce-worktree/SKILL.md +54 -0
  449. package/skills/lfg/SKILL.md +59 -0
  450. package/skills/lfg/references/debug-return.md +25 -0
  451. package/skills/lfg/references/intake.md +30 -0
  452. package/skills/lfg/references/next-work-handoff.md +96 -0
  453. package/skills/lfg/references/plan-brief.md +36 -0
  454. package/skills/lfg/references/review-followup.md +78 -0
  455. package/skills/lfg/references/shipping.md +65 -0
  456. package/skills/lfg/references/stage-routing.md +49 -0
  457. package/skills/lfg/references/task-visibility.md +11 -0
  458. package/skills/lfg/references/tracker-defer.md +150 -0
  459. package/skills/lfg/references/work-return.md +27 -0
  460. package/skills/wtf/SKILL.md +33 -0
  461. package/skills/wtf/agents/openai.yaml +2 -0
@@ -0,0 +1,3656 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic snapshot + state helper for ce-babysit-pr.
3
+
4
+ The agent (SKILL.md) owns judgment and mutations; this script owns the parts
5
+ prose cannot do reliably: a combined fetch of both event streams, atomic state
6
+ read/write under a file lock, and dedup keyed on remote truth.
7
+
8
+ The dedup model is claim -> act -> confirm. `snapshot` never marks an item
9
+ handled just because it observed it; an item stays actionable until either the
10
+ agent confirms it acted (`mark`) OR remote truth removes it (a resolved thread
11
+ drops out of the unresolved fetch). So if a resolve/debug pass crashes or fails
12
+ before the agent marks it, the item is still actionable on the next tick.
13
+
14
+ Subcommands:
15
+ snapshot --pr N [--repo O/R] --state-dir DIR [--fetch-file F]
16
+ Fetch (or load F), diff against on-disk state, persist the
17
+ observed state atomically, emit the actionable set as JSON.
18
+ mark --state-dir DIR --invocation-id ID --session-started-at TIME
19
+ --invocation-budget-seconds N (--thread ID --disposition open|dispatched
20
+ | --comment ID --disposition open|dispatched | --check KEY
21
+ | --disposition needs-human --residual-file FILE
22
+ | --answer-decision ID --answer-file FILE)
23
+ A residual-only needs-human mark atomically freezes every source observation
24
+ in FILE under one decision. Sources retain their ordinary dispositions.
25
+ Record that the agent acted on an item. A `dispatched` thread is
26
+ re-emitted when a later reviewer comment moves its last-comment
27
+ identity past the one we acted on (our own reply does not
28
+ re-trigger). A non-thread feedback item never drops out of
29
+ the fetch on its own, so `--comment` is the only way to silence a
30
+ handled one. A new head SHA clears dispatched CI checks.
31
+
32
+ --fetch-file injects a pre-captured combined snapshot instead of calling gh,
33
+ so the diff logic is testable without a live PR.
34
+ """
35
+ import argparse
36
+ import errno
37
+ import hashlib
38
+ import json
39
+ import os
40
+ import re
41
+ import signal
42
+ import subprocess
43
+ import sys
44
+ import tempfile
45
+ import threading
46
+ import time
47
+ import uuid
48
+ from contextlib import contextmanager
49
+ from datetime import datetime, timedelta, timezone
50
+ from urllib.parse import quote, urlsplit
51
+
52
+ IS_WINDOWS = sys.platform == "win32"
53
+
54
+ if IS_WINDOWS:
55
+ import msvcrt
56
+ else:
57
+ import fcntl
58
+
59
+ # Conclusions that mean "this check needs attention" (failing states).
60
+ FAILING = {"FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"}
61
+ TERMINAL_CHECK_CONCLUSIONS = {
62
+ "ACTION_REQUIRED", "CANCELLED", "FAILURE", "NEUTRAL", "SKIPPED", "STALE",
63
+ "STARTUP_FAILURE", "SUCCESS", "TIMED_OUT",
64
+ }
65
+
66
+ DISPOSITION_OPEN = "open"
67
+ DISPOSITION_NEEDS_HUMAN = "needs-human"
68
+ DISPOSITION_DISPATCHED = "dispatched"
69
+ NEEDS_HUMAN_SOURCE_KINDS = {"thread", "comment", "review", "check", "currency"}
70
+ CURRENCY_CLAIMED = "claimed"
71
+ CURRENCY_CONFIRMED = "confirmed"
72
+ CURRENCY_OUTCOME_MUTATION_OBSERVED = "mutation-observed"
73
+ CURRENCY_OUTCOME_PROVEN_NO_MUTATION = "proven-no-mutation"
74
+ CURRENCY_OUTCOME_AMBIGUOUS = "ambiguous"
75
+ CURRENCY_ATTENTION_CLAIM = "claim"
76
+ CURRENCY_ATTENTION_DECIDE = "decide"
77
+ CURRENCY_ATTENTION_INSPECT = "inspect"
78
+ CURRENCY_ATTENTION_RECONCILE = "reconcile"
79
+
80
+ MANAGER_CONFIRMED = "confirmed"
81
+ MANAGER_ABSENT = "absent"
82
+ MANAGER_PROBE_ERROR = "probe-error"
83
+ RELATIONSHIP_DEPENDENT = "dependent"
84
+ RELATIONSHIP_INDEPENDENT = "independent"
85
+ RELATIONSHIP_PROBE_ERROR = "probe-error"
86
+ BASE_REF_CURRENT = "current"
87
+ BASE_REF_RACE = "race"
88
+ BASE_REF_STALE_COMPUTATION = "stale-computation"
89
+ # GitHub can leave potentialMergeCommit parented on the old base indefinitely after main moves
90
+ # (observed 20+ min on tmchow/pr-stack-test, unrefreshed by polls or a body PATCH). Once the live
91
+ # base is stable this long with the same head, the cached CLEAN/DIRTY verdict is accepted with a
92
+ # disclosure instead of blocking readiness forever.
93
+ STALE_MERGE_COMPUTATION_SECONDS = 600
94
+ BASE_REF_MERGEABILITY_PENDING = "mergeability-pending"
95
+ BASE_REF_PROBE_ERROR = "probe-error"
96
+ BASE_REF_LEGACY_STALE = "stale"
97
+
98
+ # Quiet window a merge-ready wake ordinarily requires. The agent widens it by re-arming when it
99
+ # judges more waiting is warranted; the engine never adjusts the window it was given.
100
+ DEFAULT_SETTLE_SECONDS = 300.0
101
+
102
+ # Quiet window before a comment-only wake. Every non-empty top-level body is a candidate the
103
+ # resolver must classify, so waking on the first one spends a full ce-resolve-pr-feedback dispatch
104
+ # to conclude "status noise". Only this reason waits; real work never consults the clock.
105
+ FEEDBACK_COALESCE_SECONDS = 180
106
+
107
+ # Trajectory check-history states (persisted, compared by ==).
108
+ CHECK_UNKNOWN = "unknown"
109
+ CHECK_CLEAR = "clear"
110
+ CHECK_FAILING = "failing"
111
+ # Trajectory single-stream activity labels.
112
+ STREAM_CI = "ci"
113
+ STREAM_REVIEW = "review"
114
+ # Keep a check's recurrence memory across a transient absence (a one-tick gap from a
115
+ # workflow-registration lag or a paths-filtered run), but bound growth: evict entries
116
+ # unseen for this many ticks.
117
+ CHECK_HISTORY_TTL = 30
118
+
119
+ # One invocation is a bounded monitoring shift, not the lifetime of the PR. Eight hours covers a
120
+ # maximum-length GitHub-hosted Actions job (six hours) plus reaction and settle time. Callers may
121
+ # choose another fixed value on the first snapshot; later commands must present the same value.
122
+ DEFAULT_INVOCATION_BUDGET_SECONDS = 8 * 60 * 60
123
+ # The 8h budget is spent in *active watch-capability time*, not raw wall-clock: a span where the
124
+ # whole watch process was suspended (laptop asleep) is excluded. Detection is coarse — an activity
125
+ # gap wider than this threshold (well above the 150s poll interval) is charged to dead time, minus
126
+ # the threshold itself so ordinary polls/ticks never register. The 3-day backstop stays wall-clock.
127
+ DEAD_TIME_THRESHOLD_SECONDS = 15 * 60
128
+ DEFAULT_INVOCATION_BACKSTOP_SECONDS = 3 * 24 * 60 * 60
129
+ CURRENCY_RETRY_BACKOFF_SECONDS = 30
130
+
131
+
132
+ # --- cross-platform state primitives ------------------------------------------
133
+ #
134
+ # The watcher needs three POSIX facilities that native Windows Python does not
135
+ # have: an advisory file lock (fcntl.flock), a process-identity probe for the
136
+ # PID-reuse guard (`ps -o lstart=`), and an unconditional rename. Each Windows
137
+ # analog differs in a way that matters here, so each is named rather than
138
+ # translated literally. See
139
+ # docs/solutions/architecture-patterns/posix-process-supervision-on-native-windows.md.
140
+
141
+ # Poll spacing while waiting on a Windows lock or a blocked rename.
142
+ _WIN_RETRY_SECONDS = 0.05
143
+ # Bound only the rename retry. A lock wait is unbounded, matching flock.
144
+ _WIN_REPLACE_TIMEOUT_SECONDS = 10.0
145
+
146
+ if IS_WINDOWS:
147
+ import ctypes
148
+ from ctypes import wintypes
149
+
150
+ _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
151
+ _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
152
+
153
+ # Declare argtypes/restype explicitly: without them ctypes truncates HANDLEs
154
+ # to 32-bit ints on Win64, so a valid handle silently becomes a bad one.
155
+ _kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
156
+ _kernel32.OpenProcess.restype = wintypes.HANDLE
157
+ _kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
158
+ _kernel32.CloseHandle.restype = wintypes.BOOL
159
+ _kernel32.GetProcessTimes.argtypes = [
160
+ wintypes.HANDLE, ctypes.POINTER(wintypes.FILETIME),
161
+ ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME),
162
+ ctypes.POINTER(wintypes.FILETIME)]
163
+ _kernel32.GetProcessTimes.restype = wintypes.BOOL
164
+ _kernel32.QueryFullProcessImageNameW.argtypes = [
165
+ wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR,
166
+ ctypes.POINTER(wintypes.DWORD)]
167
+ _kernel32.QueryFullProcessImageNameW.restype = wintypes.BOOL
168
+
169
+ def _win_process_identity(pid):
170
+ """Creation time plus image path — the Windows analog of `ps -o lstart= -o command=`.
171
+
172
+ Creation time is the part that makes this a PID-reuse guard: Windows recycles PIDs
173
+ aggressively, and a recycled PID always carries a later creation time than the watcher
174
+ we recorded. Returns None when the process is gone or unopenable, which the caller
175
+ treats as "identity not proven" and declines to act on."""
176
+ handle = _kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
177
+ if not handle:
178
+ return None
179
+ try:
180
+ created, exited, kernel, user = (wintypes.FILETIME() for _ in range(4))
181
+ if not _kernel32.GetProcessTimes(
182
+ handle, ctypes.byref(created), ctypes.byref(exited),
183
+ ctypes.byref(kernel), ctypes.byref(user)):
184
+ return None
185
+ started = (created.dwHighDateTime << 32) | created.dwLowDateTime
186
+ if not started:
187
+ return None
188
+ size = wintypes.DWORD(32768)
189
+ buf = ctypes.create_unicode_buffer(size.value)
190
+ image = (buf.value if _kernel32.QueryFullProcessImageNameW(
191
+ handle, 0, buf, ctypes.byref(size)) else "")
192
+ finally:
193
+ _kernel32.CloseHandle(handle)
194
+ return "{} {}".format(started, image)
195
+
196
+
197
+ def _lock_acquire(fd, exclusive):
198
+ """Block until this process owns the lock file.
199
+
200
+ msvcrt.locking is the Windows analog of flock, with three differences. It locks a byte range
201
+ from the current offset rather than the whole file, so the offset is pinned to 0. It has no
202
+ shared mode, so a reader takes the same exclusive lock a writer does — free here, because every
203
+ critical section is a small local read/write with the network fetch already completed outside
204
+ the lock. And its blocking mode (LK_LOCK) gives up after roughly ten seconds instead of waiting,
205
+ so retrying the non-blocking mode is what actually reproduces flock's wait-for-the-holder
206
+ behavior. As on POSIX, the OS releases the lock if a holder dies, so a crash cannot wedge this."""
207
+ if not IS_WINDOWS:
208
+ fcntl.flock(fd, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
209
+ return
210
+ os.lseek(fd, 0, os.SEEK_SET)
211
+ while True:
212
+ try:
213
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
214
+ return
215
+ except OSError as e:
216
+ # Retry only genuine contention. msvcrt reports a held range as EACCES; anything
217
+ # else (EBADF from a closed descriptor, EINVAL from a bad range) is a defect that
218
+ # retrying cannot clear, and swallowing it here would spin this loop forever with
219
+ # no output — a silent hang, which for a watcher is indistinguishable from the
220
+ # "monitoring is quietly not running" failure this port exists to fix.
221
+ if e.errno != errno.EACCES:
222
+ raise
223
+ time.sleep(_WIN_RETRY_SECONDS)
224
+
225
+
226
+ def _lock_release(fd):
227
+ if not IS_WINDOWS:
228
+ fcntl.flock(fd, fcntl.LOCK_UN)
229
+ return
230
+ os.lseek(fd, 0, os.SEEK_SET)
231
+ try:
232
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
233
+ except OSError:
234
+ pass
235
+
236
+
237
+ @contextmanager
238
+ def _state_lock(state_dir, exclusive=True):
239
+ """Serialize access to the state dir for the duration of the body."""
240
+ os.makedirs(state_dir, exist_ok=True)
241
+ # O_RDWR|O_CREAT rather than a truncating open("w"): on Windows, truncating a file another
242
+ # process holds a byte-range lock on fails, which would make lock acquisition itself the race.
243
+ fd = os.open(os.path.join(state_dir, "lock"), os.O_RDWR | os.O_CREAT, 0o600)
244
+ try:
245
+ _lock_acquire(fd, exclusive)
246
+ try:
247
+ yield
248
+ finally:
249
+ _lock_release(fd)
250
+ finally:
251
+ os.close(fd)
252
+
253
+
254
+ def _replace_atomic(src, dst):
255
+ """Atomically move `src` onto `dst`.
256
+
257
+ POSIX rename always succeeds over an open destination. Windows refuses while any other handle
258
+ to `dst` is open, so an unlocked best-effort reader — or a virus scanner that opened the file
259
+ behind us — would otherwise turn a routine concurrent read into a crashed writer. Retry briefly
260
+ rather than treating that transient sharing violation as a failure."""
261
+ if not IS_WINDOWS:
262
+ os.replace(src, dst)
263
+ return
264
+ deadline = time.monotonic() + _WIN_REPLACE_TIMEOUT_SECONDS
265
+ while True:
266
+ try:
267
+ os.replace(src, dst)
268
+ return
269
+ except PermissionError:
270
+ if time.monotonic() >= deadline:
271
+ raise
272
+ time.sleep(_WIN_RETRY_SECONDS)
273
+
274
+
275
+ class _WatchSuperseded(Exception):
276
+ """Internal control flow: this watch lost ownership and must unwind immediately."""
277
+
278
+
279
+ class _InvocationSuperseded(Exception):
280
+ """Internal control flow: this watch belongs to an invocation that was replaced."""
281
+
282
+ def __init__(self, current_invocation_id):
283
+ self.current_invocation_id = current_invocation_id
284
+
285
+
286
+ def _now():
287
+ return datetime.now(timezone.utc)
288
+
289
+
290
+ def _iso(dt):
291
+ return dt.isoformat()
292
+
293
+
294
+ def _run(cmd):
295
+ # gh honors CLICOLOR_FORCE / GH_FORCE_TTY even for --json output, and a host that exports them
296
+ # (observed under orca) makes every JSON read unparsable. Pin plain output regardless of caller env.
297
+ env = os.environ.copy()
298
+ env["NO_COLOR"] = "1"
299
+ env.pop("CLICOLOR_FORCE", None)
300
+ env.pop("GH_FORCE_TTY", None)
301
+ return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", env=env)
302
+
303
+
304
+ def _run_git(cmd):
305
+ env = os.environ.copy()
306
+ env["GIT_TERMINAL_PROMPT"] = "0"
307
+ env["GCM_INTERACTIVE"] = "never"
308
+ env["GIT_ASKPASS"] = ""
309
+ env["SSH_ASKPASS"] = ""
310
+ return subprocess.run(
311
+ cmd, capture_output=True, text=True, encoding="utf-8", env=env, timeout=30,
312
+ )
313
+
314
+
315
+ def _run_checked(cmd, label):
316
+ r = _run(cmd)
317
+ if r.returncode != 0:
318
+ raise SystemExit(f"{label} failed: {r.stderr.strip()}")
319
+ return r
320
+
321
+
322
+ def _split_repo(repo):
323
+ """Parse "[HOST/]OWNER/NAME" into (owner, name), or (None, None). A host-qualified ref (gh's
324
+ documented `[HOST/]OWNER/REPO` selector) drops the host — the last two segments are what the
325
+ GraphQL lookup needs; treating the host as the owner would query a nonexistent repo on GHE."""
326
+ if not repo:
327
+ return None, None
328
+ parts = repo.strip("/").split("/")
329
+ if len(parts) >= 2:
330
+ return parts[-2], parts[-1]
331
+ return None, None
332
+
333
+
334
+ def _resolve_repo_ref(repo, url):
335
+ """Resolve (owner, name, host) from --repo + the PR url, else one `gh repo view` call.
336
+ The host is parsed from the url and threaded into every `gh api` call so a GitHub Enterprise
337
+ PR queries the right host — without it, `gh api` defaults to github.com and a GHE babysitter
338
+ fetches the PR via `gh pr view` but then reads review threads / workflow runs from github.com.
339
+ Parsing the url (already fetched by `fetch`) also avoids a redundant `gh repo view` per tick."""
340
+ owner, name = _split_repo(repo)
341
+ host = None
342
+ if url:
343
+ # https://<host>/OWNER/NAME/pull/N
344
+ parts = url.rstrip("/").split("/")
345
+ if len(parts) >= 5 and parts[0].startswith("http"):
346
+ host = parts[2]
347
+ if not owner:
348
+ owner, name = parts[-4], parts[-3]
349
+ if not owner:
350
+ r = _run(["gh", "repo", "view", "--json", "owner,name"])
351
+ if r.returncode == 0:
352
+ info = json.loads(r.stdout)
353
+ owner, name = info.get("owner", {}).get("login"), info.get("name")
354
+ if not owner or not name:
355
+ raise SystemExit("could not resolve owner/repo; pass --repo OWNER/REPO")
356
+ return owner, name, host
357
+
358
+
359
+ def _host_args(host):
360
+ """`gh api --hostname` selector so GHE calls hit the PR's host, not the default github.com."""
361
+ return ["--hostname", host] if host else []
362
+
363
+
364
+ def _api_json(path, host=None):
365
+ """Best-effort JSON API read. An incomplete corroboration must stay pending."""
366
+ result = _run(["gh", "api", *_host_args(host), path])
367
+ if result.returncode != 0:
368
+ return None
369
+ try:
370
+ value = json.loads(result.stdout)
371
+ except (TypeError, json.JSONDecodeError):
372
+ return None
373
+ return value if isinstance(value, dict) else None
374
+
375
+
376
+ def _actions_run_and_check_id(details_url, owner, name, host):
377
+ """Return trusted Actions run/check IDs only for this repository's canonical job URL."""
378
+ if not isinstance(details_url, str):
379
+ return None
380
+ parsed = urlsplit(details_url)
381
+ expected_host = (host or "github.com").lower()
382
+ if parsed.scheme != "https" or parsed.netloc.lower() != expected_host:
383
+ return None
384
+ match = re.fullmatch(
385
+ rf"/{re.escape(owner)}/{re.escape(name)}/actions/runs/(\d+)/job/(\d+)/?",
386
+ parsed.path,
387
+ )
388
+ if not match:
389
+ return None
390
+ return int(match.group(1)), int(match.group(2))
391
+
392
+
393
+ def _same_head(value, head):
394
+ return isinstance(value, str) and isinstance(head, str) and value.lower() == head.lower()
395
+
396
+
397
+ def _corroborated_terminal_check_status(check, owner, name, head, host):
398
+ """Normalize only GitHub's impossible running-but-concluded CheckRun tuple.
399
+
400
+ A CheckRun remains pending unless its own REST record, linked check suite, and
401
+ parent Actions workflow all report the same terminal conclusion on this exact
402
+ PR head. This intentionally adds no best-effort success inference.
403
+ """
404
+ if check.get("__typename") != "CheckRun":
405
+ return None
406
+ status = str(check.get("status") or "").upper()
407
+ conclusion = str(check.get("conclusion") or "").upper()
408
+ if status != "IN_PROGRESS" or not check.get("completedAt") or conclusion not in TERMINAL_CHECK_CONCLUSIONS:
409
+ return None
410
+
411
+ identifiers = _actions_run_and_check_id(check.get("detailsUrl"), owner, name, host)
412
+ if identifiers is None:
413
+ return None
414
+ workflow_run_id, job_id = identifiers
415
+ database_id = check.get("databaseId")
416
+ if database_id is not None and database_id != job_id:
417
+ return None
418
+ check_id = database_id or job_id
419
+ if not isinstance(check_id, int):
420
+ return None
421
+
422
+ check_run = _api_json(f"repos/{owner}/{name}/check-runs/{check_id}", host)
423
+ if not check_run or not _same_head(check_run.get("head_sha"), head):
424
+ return None
425
+ if (str(check_run.get("conclusion") or "").upper() != conclusion
426
+ or not check_run.get("completed_at")):
427
+ return None
428
+ suite_id = (check_run.get("check_suite") or {}).get("id")
429
+ if not isinstance(suite_id, int):
430
+ return None
431
+
432
+ job = _api_json(f"repos/{owner}/{name}/actions/jobs/{job_id}", host)
433
+ if (not job
434
+ or job.get("id") != job_id
435
+ or not _same_head(job.get("head_sha"), head)
436
+ or str(job.get("conclusion") or "").upper() != conclusion
437
+ or not job.get("completed_at")):
438
+ return None
439
+
440
+ suite = _api_json(f"repos/{owner}/{name}/check-suites/{suite_id}", host)
441
+ workflow = _api_json(f"repos/{owner}/{name}/actions/runs/{workflow_run_id}", host)
442
+ for parent in (suite, workflow):
443
+ if (not parent
444
+ or not _same_head(parent.get("head_sha"), head)
445
+ or str(parent.get("status") or "").upper() != "COMPLETED"
446
+ or str(parent.get("conclusion") or "").upper() != conclusion):
447
+ return None
448
+ return "COMPLETED"
449
+
450
+
451
+ def _valid_oid(value):
452
+ return isinstance(value, str) and bool(
453
+ re.fullmatch(r"(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})", value))
454
+
455
+
456
+ def fetch_pr_merge_identity(pr, owner, name, host=None):
457
+ """Read mergeability and the identities it was computed from in one GraphQL observation."""
458
+ query = """
459
+ query($owner:String!,$repo:String!,$pr:Int!){
460
+ repository(owner:$owner,name:$repo){ pullRequest(number:$pr){
461
+ mergeable mergeStateStatus headRefOid baseRefOid baseRefName viewerCanUpdateBranch
462
+ baseRef { target { oid } }
463
+ headRef { target { ... on Commit { parents(first:2) { nodes { oid } } } } }
464
+ potentialMergeCommit { oid parents(first:2) { nodes { oid } } }
465
+ } }
466
+ }"""
467
+ r = _run(["gh", "api", "graphql", *_host_args(host), "-f", f"owner={owner}",
468
+ "-f", f"repo={name}", "-F", f"pr={pr}", "-f", f"query={query}"])
469
+ if r.returncode != 0:
470
+ return None
471
+ try:
472
+ value = json.loads(r.stdout)["data"]["repository"]["pullRequest"]
473
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError):
474
+ return None
475
+ return value if isinstance(value, dict) else None
476
+
477
+
478
+ def fetch_base_ref(owner, name, ref, merge_identity, host=None):
479
+ """Bind GitHub's mergeability observation to an independently probed current base ref.
480
+
481
+ `baseRefOid` is retained only as historical PR metadata. Current identity comes from both the
482
+ PR's `baseRef.target.oid` and an exact Git-ref read; a usable generated test merge must then
483
+ name that base and the observed PR head as its two parents. Some valid private-repository OAuth
484
+ sessions return 404 from the REST Git-ref endpoint, so that response falls back to the Git
485
+ transport while preserving exact-ref matching and non-interactive failure behavior.
486
+ """
487
+ historical_oid = (merge_identity or {}).get("baseRefOid")
488
+ head_oid = (merge_identity or {}).get("headRefOid")
489
+ mergeable = (merge_identity or {}).get("mergeable")
490
+ merge_state_status = (merge_identity or {}).get("mergeStateStatus")
491
+ graphql_oid = (((merge_identity or {}).get("baseRef") or {}).get("target") or {}).get("oid")
492
+ potential_merge = (merge_identity or {}).get("potentialMergeCommit")
493
+ merge_commit_oid = potential_merge.get("oid") if isinstance(potential_merge, dict) else None
494
+ merge_parent_oids = (((potential_merge or {}).get("parents") or {}).get("nodes")
495
+ if isinstance(potential_merge, dict) else None)
496
+ if isinstance(merge_parent_oids, list):
497
+ merge_parent_oids = [node.get("oid") if isinstance(node, dict) else None
498
+ for node in merge_parent_oids]
499
+ else:
500
+ merge_parent_oids = []
501
+ base = {
502
+ "host": host or "github.com",
503
+ "repository": f"{owner}/{name}",
504
+ "ref": ref,
505
+ "oid": None,
506
+ "graphql_oid": graphql_oid,
507
+ "historical_oid": historical_oid,
508
+ "merge_commit_oid": merge_commit_oid,
509
+ "merge_parent_oids": merge_parent_oids,
510
+ "identity": BASE_REF_PROBE_ERROR,
511
+ }
512
+ if not ref:
513
+ return base
514
+ encoded_ref = quote(ref, safe="/")
515
+ r = _run([
516
+ "gh", "api", *_host_args(host),
517
+ f"repos/{owner}/{name}/git/ref/heads/{encoded_ref}",
518
+ "--jq", ".object.sha",
519
+ ])
520
+ current_oid = (r.stdout or "").strip()
521
+ if r.returncode != 0 and re.search(r"\bHTTP\s+404\b", r.stderr or "", re.IGNORECASE):
522
+ exact_ref = f"refs/heads/{ref}"
523
+ remote_url = (f"https://{host or 'github.com'}/"
524
+ f"{quote(owner, safe='')}/{quote(name, safe='')}.git")
525
+ try:
526
+ # Reuse the gh session (GH_TOKEN or keyring) as git's credential source so a
527
+ # headless host with no configured Git credential helper is not anonymous here.
528
+ git_result = _run_git([
529
+ "git", "-c", "core.askPass=",
530
+ "-c", "credential.helper=",
531
+ "-c", "credential.helper=!gh auth git-credential",
532
+ "ls-remote", "--exit-code", "--refs",
533
+ remote_url, exact_ref,
534
+ ])
535
+ except (subprocess.TimeoutExpired, OSError):
536
+ git_result = None
537
+ if git_result is not None and git_result.returncode == 0:
538
+ matches = []
539
+ for line in (git_result.stdout or "").splitlines():
540
+ fields = line.split()
541
+ if len(fields) == 2 and fields[1] == exact_ref and _valid_oid(fields[0]):
542
+ matches.append(fields[0])
543
+ if len(matches) == 1:
544
+ current_oid = matches[0]
545
+ r = git_result
546
+ if r.returncode != 0 or not _valid_oid(current_oid):
547
+ return base
548
+ base["oid"] = current_oid
549
+ if not (_valid_oid(graphql_oid) and _valid_oid(head_oid)):
550
+ return base
551
+ if current_oid.lower() != graphql_oid.lower():
552
+ base["identity"] = BASE_REF_RACE
553
+ return base
554
+
555
+ if mergeable == "MERGEABLE" and merge_state_status not in (None, "UNKNOWN", "DIRTY"):
556
+ if potential_merge is None:
557
+ base["identity"] = BASE_REF_MERGEABILITY_PENDING
558
+ return base
559
+ if (not _valid_oid(merge_commit_oid) or len(merge_parent_oids) != 2
560
+ or not all(_valid_oid(oid) for oid in merge_parent_oids)):
561
+ return base
562
+ if (merge_parent_oids[0].lower() != current_oid.lower()
563
+ or merge_parent_oids[1].lower() != head_oid.lower()):
564
+ base["identity"] = BASE_REF_RACE
565
+ # Only the base parent lags while head and live base agree: a stale test merge,
566
+ # eligible for the bounded degrade in diff().
567
+ base["stale_computation_candidate"] = (
568
+ merge_parent_oids[1].lower() == head_oid.lower())
569
+ return base
570
+ elif not (mergeable == "CONFLICTING" and merge_state_status == "DIRTY"):
571
+ base["identity"] = BASE_REF_MERGEABILITY_PENDING
572
+ return base
573
+
574
+ base["identity"] = BASE_REF_CURRENT
575
+ return base
576
+
577
+
578
+ def _base_ref_blocker(base):
579
+ identity = (base or {}).get("identity")
580
+ if identity is None:
581
+ # Compatibility for pre-change --fetch-file fixtures and persisted snapshots.
582
+ identity = (base or {}).get("freshness")
583
+ if identity in (BASE_REF_CURRENT, BASE_REF_STALE_COMPUTATION):
584
+ return None
585
+ if identity in (BASE_REF_RACE, BASE_REF_MERGEABILITY_PENDING):
586
+ return identity
587
+ if identity == BASE_REF_LEGACY_STALE:
588
+ return BASE_REF_LEGACY_STALE
589
+ return BASE_REF_PROBE_ERROR
590
+
591
+
592
+ def _stack_schema_unavailable(stderr):
593
+ """Recognize only GraphQL schema errors for the private-preview PullRequest stack fields."""
594
+ unavailable_markers = ("doesn't exist on type", "does not exist on type",
595
+ "cannot query field", "unknown field")
596
+ for line in (stderr or "").splitlines():
597
+ lowered = line.lower()
598
+ if ("pullrequest" in lowered
599
+ and re.search(r"\bstack(?:entry)?\b", lowered)
600
+ and any(marker in lowered for marker in unavailable_markers)):
601
+ return True
602
+ return False
603
+
604
+
605
+ def _fetch_default_branch(owner, name, host=None):
606
+ """Read the default branch without relying on private-preview GraphQL fields."""
607
+ result = _run(["gh", "api", *_host_args(host), f"repos/{owner}/{name}",
608
+ "--jq", ".default_branch"])
609
+ if result.returncode != 0:
610
+ return None
611
+ branch = result.stdout.strip()
612
+ return branch if branch and branch != "null" else None
613
+
614
+
615
+ def _thread_identity(t):
616
+ """The remote-truth identity of a thread's latest state."""
617
+ return (t.get("last_comment_id"), t.get("last_comment_at"))
618
+
619
+
620
+ def _prior_thread_activity_identity(t):
621
+ """The last external-review baseline already accepted for this parked thread."""
622
+ acted_identity = t.get("acted_identity")
623
+ if (t.get("disposition") in (DISPOSITION_DISPATCHED, DISPOSITION_NEEDS_HUMAN)
624
+ and isinstance(acted_identity, (list, tuple))
625
+ and len(acted_identity) == 2):
626
+ return tuple(acted_identity)
627
+ return _thread_identity(t)
628
+
629
+
630
+ def _default_pr_chain():
631
+ """Backward-compatible neutral shape for injected/older snapshots with no chain facts."""
632
+ return {
633
+ "manager_status": MANAGER_ABSENT,
634
+ "manager_source": None,
635
+ "relationship_status": RELATIONSHIP_INDEPENDENT,
636
+ "trunk": None,
637
+ "default_branch": None,
638
+ "current_branch": None,
639
+ "target_position": None,
640
+ "target_needs_rebase": None,
641
+ "upstack_needs_rebase": [],
642
+ "entries": [],
643
+ "parent_prs": [],
644
+ "dependent_prs": [],
645
+ }
646
+
647
+
648
+ def _pr_summary(pr):
649
+ if not pr:
650
+ return None
651
+ return {k: pr.get(k) for k in ("number", "url", "state", "isDraft", "baseRefName", "headRefName")
652
+ if pr.get(k) is not None}
653
+
654
+
655
+ def _pr_url_identity(url):
656
+ """Normalize a GitHub PR URL to a repository-scoped identity tuple."""
657
+ if not isinstance(url, str):
658
+ return None
659
+ try:
660
+ parsed = urlsplit(url.strip())
661
+ parts = [part for part in parsed.path.split("/") if part]
662
+ if (parsed.scheme.lower() not in ("http", "https") or not parsed.netloc
663
+ or len(parts) < 4 or parts[-2].lower() != "pull"):
664
+ return None
665
+ number = int(parts[-1])
666
+ except (TypeError, ValueError):
667
+ return None
668
+ return (parsed.netloc.lower(), parts[-4].lower(), parts[-3].lower(), number)
669
+
670
+
671
+ def _chain_from_entries(entries, pr, source, trunk=None, current_branch=None,
672
+ manager_id=None, manager_number=None, target_url=None):
673
+ """Normalize an ordered manager-owned chain and locate the requested PR within it."""
674
+ target_identity = _pr_url_identity(target_url) if target_url is not None else None
675
+ target_index = next((i for i, e in enumerate(entries)
676
+ if (e.get("number") == pr or str(e.get("number")) == str(pr))
677
+ and (target_url is None
678
+ or (target_identity is not None
679
+ and _pr_url_identity(e.get("url")) == target_identity))), None)
680
+ if target_index is None:
681
+ return None
682
+ target = entries[target_index]
683
+ upstack_stale = [
684
+ {k: e.get(k) for k in ("number", "position", "name", "url") if e.get(k) is not None}
685
+ for e in entries[target_index + 1:] if e.get("needs_rebase") is True
686
+ ]
687
+ chain = _default_pr_chain()
688
+ chain.update({
689
+ "manager_status": MANAGER_CONFIRMED,
690
+ "manager_source": source,
691
+ "manager_id": manager_id,
692
+ "manager_number": manager_number,
693
+ "relationship_status": RELATIONSHIP_DEPENDENT if len(entries) > 1 else RELATIONSHIP_INDEPENDENT,
694
+ "trunk": trunk,
695
+ "current_branch": current_branch,
696
+ "target_position": target.get("position") or target_index + 1,
697
+ "target_needs_rebase": target.get("needs_rebase"),
698
+ "upstack_needs_rebase": upstack_stale,
699
+ "entries": entries,
700
+ "parent_prs": [_pr_summary(entries[target_index - 1])] if target_index > 0 else [],
701
+ "dependent_prs": [_pr_summary(e) for e in entries[target_index + 1:]],
702
+ })
703
+ return chain
704
+
705
+
706
+ def _chain_from_stack_view(raw, pr, target_url):
707
+ entries = []
708
+ for index, branch in enumerate((raw or {}).get("branches") or []):
709
+ remote_pr = branch.get("pr") or {}
710
+ entries.append({
711
+ "position": index + 1,
712
+ "name": branch.get("name"),
713
+ "head": branch.get("head"),
714
+ "base": branch.get("base"),
715
+ "is_current": bool(branch.get("isCurrent")),
716
+ "is_merged": bool(branch.get("isMerged")),
717
+ "is_queued": bool(branch.get("isQueued")),
718
+ "needs_rebase": branch.get("needsRebase"),
719
+ "number": remote_pr.get("number"),
720
+ "url": remote_pr.get("url"),
721
+ "state": remote_pr.get("state"),
722
+ "isDraft": remote_pr.get("isDraft"),
723
+ })
724
+ return _chain_from_entries(entries, pr, "gh-stack", (raw or {}).get("trunk"),
725
+ (raw or {}).get("currentBranch"), target_url=target_url)
726
+
727
+
728
+ def _fetch_graphql_stack(pr, owner, name, host=None):
729
+ """Read-only remote membership fallback. A successful null stack is distinct from failure."""
730
+ query = """
731
+ query($owner:String!,$repo:String!,$pr:Int!){
732
+ repository(owner:$owner,name:$repo){
733
+ defaultBranchRef{ name }
734
+ pullRequest(number:$pr){
735
+ stackEntry{ position }
736
+ stack{ id number size baseRefName
737
+ entries(first:100){ nodes{ position pullRequest{
738
+ number url state isDraft baseRefName headRefName headRefOid
739
+ } } }
740
+ }
741
+ } }
742
+ }"""
743
+ args = ["gh", "api", "graphql", *_host_args(host), "-f", f"owner={owner}", "-f", f"repo={name}",
744
+ "-F", f"pr={pr}", "-f", f"query={query}"]
745
+ r = _run(args)
746
+ if r.returncode != 0:
747
+ if _stack_schema_unavailable(r.stderr):
748
+ default_branch = _fetch_default_branch(owner, name, host)
749
+ if default_branch:
750
+ return MANAGER_ABSENT, None, default_branch
751
+ return MANAGER_PROBE_ERROR, None, None
752
+ try:
753
+ repository = json.loads(r.stdout)["data"]["repository"]
754
+ default_branch = (repository.get("defaultBranchRef") or {}).get("name")
755
+ node = repository["pullRequest"]
756
+ stack = node.get("stack")
757
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError):
758
+ return MANAGER_PROBE_ERROR, None, None
759
+ if stack is None:
760
+ return MANAGER_ABSENT, None, default_branch
761
+ entries = []
762
+ for raw in (stack.get("entries") or {}).get("nodes") or []:
763
+ remote_pr = raw.get("pullRequest") or {}
764
+ entries.append({
765
+ "position": raw.get("position"),
766
+ "name": remote_pr.get("headRefName"),
767
+ "head": remote_pr.get("headRefOid"),
768
+ "base_ref_name": remote_pr.get("baseRefName"),
769
+ "needs_rebase": None,
770
+ **(_pr_summary(remote_pr) or {}),
771
+ })
772
+ chain = _chain_from_entries(entries, pr, "graphql", stack.get("baseRefName"),
773
+ manager_id=stack.get("id"), manager_number=stack.get("number"))
774
+ return ((MANAGER_CONFIRMED, chain, default_branch) if chain
775
+ else (MANAGER_PROBE_ERROR, None, default_branch))
776
+
777
+
778
+ def _manual_relationships(pr, repo, base_ref_name, head_ref_name, owner, name,
779
+ host=None, default_branch=None):
780
+ """Find ordinary parent/children with narrow read-only branch filters."""
781
+ repo_ref = repo or f"{owner}/{name}"
782
+ if host and repo_ref.count("/") == 1:
783
+ repo_ref = f"{host}/{repo_ref}"
784
+ fields = "number,url,state,isDraft,baseRefName,headRefName"
785
+ parent_result = None
786
+ if base_ref_name and base_ref_name != default_branch:
787
+ parent_result = _run(["gh", "pr", "list", "--repo", repo_ref, "--state", "all",
788
+ "--head", base_ref_name, "--limit", "20", "--json", fields])
789
+ dependent_result = _run(["gh", "pr", "list", "--repo", repo_ref, "--state", "open",
790
+ "--base", head_ref_name or "", "--limit", "100", "--json", fields])
791
+ if ((parent_result is not None and parent_result.returncode != 0)
792
+ or dependent_result.returncode != 0):
793
+ return RELATIONSHIP_PROBE_ERROR, [], []
794
+ try:
795
+ parent_candidates = json.loads(parent_result.stdout) or [] if parent_result else []
796
+ dependent_candidates = json.loads(dependent_result.stdout) or []
797
+ except (TypeError, ValueError, json.JSONDecodeError):
798
+ return RELATIONSHIP_PROBE_ERROR, [], []
799
+ parents = [_pr_summary(p) for p in parent_candidates
800
+ if p.get("number") != pr and p.get("headRefName") == base_ref_name]
801
+ dependents = [_pr_summary(p) for p in dependent_candidates
802
+ if p.get("number") != pr and p.get("state") == "OPEN"
803
+ and p.get("baseRefName") == head_ref_name]
804
+ status = RELATIONSHIP_DEPENDENT if parents or dependents else RELATIONSHIP_INDEPENDENT
805
+ return status, parents, dependents
806
+
807
+
808
+ def fetch_pr_chain(pr, repo, url, base_ref_name, head_ref_name, owner, name, host=None):
809
+ """Classify manager membership and ordinary dependency relationships without mutation.
810
+
811
+ The local manager is the fast/rich path, but its output is accepted only when it contains the
812
+ requested PR. `gh stack view` has no target argument and may describe a different current stack.
813
+ """
814
+ local = _run(["gh", "stack", "view", "--json"])
815
+ if local.returncode == 0:
816
+ try:
817
+ chain = _chain_from_stack_view(json.loads(local.stdout), pr, url)
818
+ except (TypeError, ValueError, json.JSONDecodeError):
819
+ chain = None
820
+ if chain:
821
+ return chain
822
+
823
+ manager_status, chain, default_branch = _fetch_graphql_stack(pr, owner, name, host)
824
+ if manager_status == MANAGER_CONFIRMED:
825
+ chain["default_branch"] = default_branch
826
+ return chain
827
+
828
+ relationship, parents, dependents = _manual_relationships(
829
+ pr, repo, base_ref_name, head_ref_name, owner, name, host, default_branch)
830
+ result = _default_pr_chain()
831
+ result.update({
832
+ "manager_status": manager_status,
833
+ "relationship_status": relationship,
834
+ "default_branch": default_branch,
835
+ "parent_prs": parents,
836
+ "dependent_prs": dependents,
837
+ })
838
+ return result
839
+
840
+
841
+ def fetch(pr, repo, light=False):
842
+ """Fetch both event streams via gh into one combined snapshot dict. `light` skips the probes a
843
+ downstack signature never reads (merge identity/base ref, eyes reactions, awaiting-approval)."""
844
+ repo_args = ["--repo", repo] if repo else []
845
+ view = _run_checked(["gh", "pr", "view", str(pr), *repo_args, "--json",
846
+ "state,mergeable,mergeStateStatus,reviewDecision,headRefOid,baseRefName,headRefName,url,number,isDraft,"
847
+ "statusCheckRollup,author,comments,reviews"],
848
+ "gh pr view")
849
+ v = json.loads(view.stdout)
850
+
851
+ owner, name, host = _resolve_repo_ref(repo, v.get("url"))
852
+ head = v.get("headRefOid")
853
+ checks = []
854
+ for c in v.get("statusCheckRollup") or []:
855
+ # CheckRun entries carry startedAt/completedAt; StatusContext (legacy commit statuses)
856
+ # carry createdAt. Preserve the provider's real creation identity for rerun reconciliation.
857
+ if c.get("__typename") == "StatusContext":
858
+ check_name = c.get("context") or "status"
859
+ state = (c.get("state") or "").upper()
860
+ checks.append({
861
+ "key": check_name, "name": check_name,
862
+ "status": "COMPLETED" if state in ("SUCCESS", "FAILURE", "ERROR") else "IN_PROGRESS",
863
+ "conclusion": {"ERROR": "FAILURE"}.get(state, state) or None,
864
+ "details_url": c.get("targetUrl"),
865
+ "created_at": c.get("createdAt"),
866
+ "started_at": None,
867
+ "completed_at": None,
868
+ })
869
+ else:
870
+ wf = c.get("workflowName")
871
+ check_name = c.get("name") or "check"
872
+ status = (c.get("status") or "").upper() or "IN_PROGRESS"
873
+ corroborated_status = None if light else _corroborated_terminal_check_status(
874
+ c, owner, name, head, host)
875
+ if corroborated_status:
876
+ status = corroborated_status
877
+ checks.append({
878
+ "key": f"{wf}/{check_name}" if wf else check_name, "name": check_name,
879
+ "status": status,
880
+ "conclusion": (c.get("conclusion") or None) and c["conclusion"].upper(),
881
+ "details_url": c.get("detailsUrl"),
882
+ "created_at": None,
883
+ "started_at": c.get("startedAt"),
884
+ "completed_at": c.get("completedAt"),
885
+ })
886
+
887
+ merge_identity = None if light else fetch_pr_merge_identity(v.get("number") or pr, owner, name, host)
888
+ merge_observation = merge_identity or v
889
+ mergeable = merge_observation.get("mergeable")
890
+ merge_state_status = merge_observation.get("mergeStateStatus")
891
+ if light:
892
+ base = {"host": host or "github.com", "repository": f"{owner}/{name}",
893
+ "ref": v.get("baseRefName"), "oid": None, "identity": BASE_REF_PROBE_ERROR}
894
+ else:
895
+ base = fetch_base_ref(owner, name, v.get("baseRefName"), merge_observation, host)
896
+ if merge_identity is not None:
897
+ identity_head = merge_identity.get("headRefOid")
898
+ identity_base_ref = merge_identity.get("baseRefName")
899
+ if (not isinstance(identity_base_ref, str)
900
+ or not (_valid_oid(identity_head) and _valid_oid(head))):
901
+ base["identity"] = BASE_REF_PROBE_ERROR
902
+ elif (identity_head.lower() != head.lower()
903
+ or identity_base_ref != v.get("baseRefName")):
904
+ # The checks and review payload came from `v`; do not combine them with mergeability
905
+ # observed for a different head or base branch.
906
+ base["identity"] = BASE_REF_RACE
907
+ pr_chain = fetch_pr_chain(v.get("number") or pr, repo, v.get("url"),
908
+ v.get("baseRefName"), v.get("headRefName"), owner, name, host)
909
+ currency_probe = {
910
+ "mergeable": mergeable,
911
+ "merge_state_status": merge_state_status,
912
+ "base": base,
913
+ "pr_chain": pr_chain,
914
+ }
915
+ host_branch_update_capability = "unknown"
916
+ if (merge_state_status == "BEHIND"
917
+ and _branch_currency_observation(currency_probe, head) is not None):
918
+ capability = merge_observation.get("viewerCanUpdateBranch")
919
+ if isinstance(capability, bool):
920
+ host_branch_update_capability = capability
921
+
922
+ return {
923
+ "pr_state": v.get("state"),
924
+ "pr_is_draft": v.get("isDraft"),
925
+ "mergeable": mergeable,
926
+ "merge_state_status": merge_state_status,
927
+ "review_decision": v.get("reviewDecision") or None,
928
+ "head_sha": head,
929
+ "base": base,
930
+ "host_branch_update_capability": host_branch_update_capability,
931
+ "url": v.get("url"),
932
+ "checks": checks,
933
+ "threads": fetch_threads(pr, owner, name, host),
934
+ # Non-thread feedback: top-level PR comments + review submission bodies. ce-resolve-pr-feedback
935
+ # handles these too, so a Changes-Requested review body or an actionable top-level comment with
936
+ # NO inline thread must not be invisible to the loop. Content-actionability is entirely the
937
+ # resolver's judgment; this deterministic layer keeps every non-empty non-author body.
938
+ "feedback": _extract_feedback(v),
939
+ "awaiting_approval": None if light else fetch_awaiting_approval(owner, name, head, host),
940
+ "head_parents": _head_parents(merge_identity),
941
+ "pr_chain": pr_chain,
942
+ }
943
+
944
+
945
+ def _body_hash(body):
946
+ """Edit identity for a top-level comment / review body: `gh pr view --json` exposes no
947
+ updatedAt, so hash the body. Feeds the external_review_moved activity signal (an edit wakes
948
+ the watch and extends the settle clock) and `needs-human` reactivation (a human may answer a
949
+ parked question by editing the same comment), but deliberately NOT `dispatched` reactivation:
950
+ status bots rewrite their bodies on every push, so edit-keyed reactivation of handled items
951
+ re-actionized bot comments forever (#1309)."""
952
+ return hashlib.sha1((body or "").encode("utf-8")).hexdigest()[:16]
953
+
954
+
955
+ def _extract_feedback(v):
956
+ """Every non-empty top-level PR comment and review body, whoever wrote it.
957
+ `gh pr view --json comments,reviews` returns flat arrays (not GraphQL {nodes}). Content,
958
+ identity, and surface are evidence for the resolver to judge, not deterministic exclusions --
959
+ and that includes the PR author, whose own top-level "please rename X" is the ordinary way a
960
+ human asks an agent-opened PR for a change. Excluding the author here made that request
961
+ invisible to the tick, so no resolver pass ever ran for it. Loop prevention is the dispatched
962
+ mark, which silences a candidate permanently once it has been classified, not identity."""
963
+ out = []
964
+ for c in v.get("comments") or []:
965
+ if (c.get("body") or "").strip():
966
+ out.append({"id": c.get("id"), "kind": "comment",
967
+ "author": (c.get("author") or {}).get("login"),
968
+ "edit_id": _body_hash(c.get("body"))})
969
+ for r in v.get("reviews") or []:
970
+ if (r.get("body") or "").strip():
971
+ out.append({"id": r.get("id"), "kind": "review",
972
+ "author": (r.get("author") or {}).get("login"),
973
+ "state": r.get("state"), "edit_id": _body_hash(r.get("body"))})
974
+ return out
975
+
976
+
977
+ def _head_parents(merge_identity):
978
+ """Parent OIDs of the PR head from the merge-identity GraphQL read, or None when absent. Feeds
979
+ the unrequested base-merge detector: a two-parent head whose second parent is the base tip,
980
+ produced without a claimed branch_currency item, is a base-into-head merge nobody asked for."""
981
+ nodes = ((((merge_identity or {}).get("headRef") or {}).get("target") or {})
982
+ .get("parents") or {}).get("nodes")
983
+ if not isinstance(nodes, list):
984
+ return None
985
+ oids = [node.get("oid") if isinstance(node, dict) else None for node in nodes]
986
+ return [str(o).lower() for o in oids if _valid_oid(o)]
987
+
988
+
989
+ def fetch_awaiting_approval(owner, name, head, host=None):
990
+ """Count Actions workflow runs on this head that are awaiting maintainer approval —
991
+ the fork-PR security gate. Such a run has created NO check-run yet, so it is invisible
992
+ to statusCheckRollup; without this, a fork PR blocked on approval reads as 'all checks ok'.
993
+ Best-effort: an API/permission failure returns None so the tick can preserve the
994
+ last proven gate state rather than treating an unknown result as a proven clear."""
995
+ if not head:
996
+ return 0
997
+ r = _run(["gh", "api", *_host_args(host),
998
+ f"repos/{owner}/{name}/actions/runs?head_sha={head}&per_page=50",
999
+ "--jq", '[.workflow_runs[] | select(.status==\"action_required\" or .status==\"waiting\" '
1000
+ 'or .conclusion==\"action_required\")] | length'])
1001
+ if r.returncode != 0:
1002
+ return None
1003
+ try:
1004
+ return int((r.stdout or "").strip() or 0)
1005
+ except ValueError:
1006
+ return None
1007
+
1008
+
1009
+ def fetch_threads(pr, owner, name, host=None):
1010
+ """Unresolved review threads with their last-comment identity."""
1011
+ query = """
1012
+ query($owner:String!,$repo:String!,$pr:Int!,$cursor:String){
1013
+ repository(owner:$owner,name:$repo){ pullRequest(number:$pr){
1014
+ reviewThreads(first:100,after:$cursor){
1015
+ nodes{ id isResolved path line
1016
+ root: comments(first:1){ nodes{ url } }
1017
+ comments(last:100){ nodes{ id createdAt lastEditedAt } } }
1018
+ pageInfo{ hasNextPage endCursor } } } } }"""
1019
+ out = []
1020
+ cursor = None
1021
+ while True:
1022
+ args = ["gh", "api", "graphql", *_host_args(host), "-f", f"owner={owner}", "-f", f"repo={name}",
1023
+ "-F", f"pr={pr}", "-f", f"query={query}"]
1024
+ if cursor:
1025
+ args += ["-f", f"cursor={cursor}"]
1026
+ r = _run_checked(args, "gh api graphql")
1027
+ data = json.loads(r.stdout)["data"]["repository"]["pullRequest"]["reviewThreads"]
1028
+ for n in data["nodes"]:
1029
+ if n.get("isResolved"):
1030
+ continue
1031
+ cs = n.get("comments", {}).get("nodes") or []
1032
+ last = cs[-1] if cs else {}
1033
+ # `last_comment_at` is the reactivation signal — the MAX edit/create time across every
1034
+ # comment in the thread, not just the last one, so a reviewer editing an *earlier* comment
1035
+ # (their original request) after the agent's reply still moves the identity and re-opens it.
1036
+ # Bounded to the last 100 comments (see the query): review threads are ~never that long; an
1037
+ # edit to a comment outside that window would be missed (acceptable vs paginating per thread).
1038
+ edit_at = max((c.get("lastEditedAt") or c.get("createdAt") or "" for c in cs), default="")
1039
+ root = (n.get("root") or {}).get("nodes") or []
1040
+ out.append({
1041
+ "thread_id": n["id"],
1042
+ "last_comment_id": last.get("id"),
1043
+ "last_comment_at": edit_at or last.get("lastEditedAt") or last.get("createdAt"),
1044
+ "url": root[0].get("url") if root else None,
1045
+ "path": n.get("path"),
1046
+ "line": n.get("line"),
1047
+ })
1048
+ if not data["pageInfo"]["hasNextPage"]:
1049
+ break
1050
+ cursor = data["pageInfo"]["endCursor"]
1051
+ return out
1052
+
1053
+
1054
+ def _empty_state(pr, repo, url, now):
1055
+ owner, name = _split_repo(repo)
1056
+ created_at = _iso(now)
1057
+ return {
1058
+ "pr": {"owner": owner, "repo": name, "number": pr, "url": url},
1059
+ "head_sha": None, "tick": 0, "state_created_at": created_at,
1060
+ "started_at": created_at, "invocation_id": None, "invocation_budget_seconds": None,
1061
+ "last_activity_at": created_at, "dead_time_seconds": 0.0,
1062
+ "invocation_backstop_seconds": DEFAULT_INVOCATION_BACKSTOP_SECONDS,
1063
+ "checks": {}, "threads": {}, "ci_dispatched": {},
1064
+ "human_decisions": [], "answered_human_decisions": [],
1065
+ "review_decision": None, "mergeable": None, "merge_state_status": None,
1066
+ "blocked_external_head_sha": None,
1067
+ "blocked_external_first_seen_at": None,
1068
+ "feedback_candidate_changed_at": None, "feedback_candidate_ids": [],
1069
+ "blocked_external_review_last_activity_at": None,
1070
+ "pr_chain": _default_pr_chain(),
1071
+ "base": None,
1072
+ "branch_currency_state": _empty_branch_currency_state(),
1073
+ "last_change_at": None, "last_action": None, "stop_reason": None,
1074
+ "watch_generation": None, "watch_pid": None, "watch_process_identity": None,
1075
+ "trajectory": _empty_trajectory(),
1076
+ }
1077
+
1078
+
1079
+ def _empty_branch_currency_state():
1080
+ return {"current_key": None, "items": {}, "semantic_parks": {}, "head_sha": None}
1081
+
1082
+
1083
+ def _load_branch_currency_state(state):
1084
+ """Conservatively migrate durable state written before branch currency existed."""
1085
+ currency = state.get("branch_currency_state")
1086
+ if not isinstance(currency, dict):
1087
+ currency = {}
1088
+ defaults = _empty_branch_currency_state()
1089
+ for key, value in defaults.items():
1090
+ currency.setdefault(key, value)
1091
+ if not isinstance(currency.get("items"), dict):
1092
+ currency["items"] = {}
1093
+ for item in currency["items"].values():
1094
+ if isinstance(item, dict) and item.get("requires_decision"):
1095
+ item.setdefault("mutation_requires_answer", True)
1096
+ if not isinstance(currency.get("semantic_parks"), dict):
1097
+ currency["semantic_parks"] = {}
1098
+ state["branch_currency_state"] = currency
1099
+ return currency
1100
+
1101
+
1102
+ def _reactivate_currency_item(currency, item):
1103
+ """Re-actionize one source without erasing attempt or mutation history."""
1104
+ item["disposition"] = DISPOSITION_OPEN
1105
+ item.pop("claimed_invocation_id", None)
1106
+ item.pop("confirmed_invocation_id", None)
1107
+ item.pop("reconciled_invocation_id", None)
1108
+ item.pop("inspected_semantic_conflict_fingerprint", None)
1109
+ item.pop("inspection_result", None)
1110
+ item.pop("requires_decision", None)
1111
+ item.pop("pending_decision", None)
1112
+ item.pop("answered_decision", None)
1113
+ item["parked_semantic_fingerprints"] = sorted(currency.get("semantic_parks") or {})
1114
+
1115
+
1116
+ def _mergeability_certain(mergeable, merge_state_status, base):
1117
+ """GitHub mergeability is usable only when its cached base is proven current."""
1118
+ if _base_ref_blocker(base) is not None:
1119
+ return False
1120
+ if mergeable not in ("MERGEABLE", "CONFLICTING"):
1121
+ return False
1122
+ if not merge_state_status or merge_state_status == "UNKNOWN":
1123
+ return False
1124
+ if merge_state_status == "DIRTY":
1125
+ return mergeable == "CONFLICTING"
1126
+ if merge_state_status == "BEHIND":
1127
+ return mergeable == "MERGEABLE"
1128
+ return True
1129
+
1130
+
1131
+ def _normal_base_route(cur):
1132
+ """Return the only route U1 may classify: an unmanaged PR rooted on its normal base.
1133
+
1134
+ Ordinary dependents are intentionally harmless. An open or unknown parent means this target is
1135
+ itself in a manual dependency chain and therefore outside this unit's authority.
1136
+ """
1137
+ chain = cur.get("pr_chain") or _default_pr_chain()
1138
+ base = cur.get("base") or {}
1139
+ if chain.get("manager_status") != MANAGER_ABSENT:
1140
+ return None
1141
+ if chain.get("relationship_status") == RELATIONSHIP_PROBE_ERROR:
1142
+ return None
1143
+ if not chain.get("default_branch") or base.get("ref") != chain.get("default_branch"):
1144
+ return None
1145
+ parents = chain.get("parent_prs") or []
1146
+ if any(not isinstance(parent, dict) or parent.get("state") not in ("CLOSED", "MERGED")
1147
+ for parent in parents):
1148
+ return None
1149
+ return "normal-base"
1150
+
1151
+
1152
+ def _branch_currency_observation(cur, head):
1153
+ status = cur.get("merge_state_status")
1154
+ certain = _mergeability_certain(cur.get("mergeable"), status, cur.get("base"))
1155
+ if not certain or status not in ("BEHIND", "DIRTY"):
1156
+ return None
1157
+ route = _normal_base_route(cur)
1158
+ base = cur.get("base") or {}
1159
+ if (route is None or not head
1160
+ or not all(base.get(key) for key in ("host", "repository", "ref", "oid"))):
1161
+ return None
1162
+ identity = {
1163
+ "host": base["host"],
1164
+ "base_repository": base["repository"],
1165
+ "base_ref": base["ref"],
1166
+ "base_oid": base["oid"],
1167
+ "head_sha": head,
1168
+ "status": status,
1169
+ "route": route,
1170
+ }
1171
+ encoded = json.dumps(identity, sort_keys=True, separators=(",", ":")).encode("utf-8")
1172
+ return {"key": f"currency:{hashlib.sha256(encoded).hexdigest()}", **identity}
1173
+
1174
+
1175
+ def _claimed_currency_items(currency):
1176
+ """Return unresolved mutation claims with the current item first.
1177
+
1178
+ Base or expected head movement may be the result of the claimed mutation. It is evidence to
1179
+ reconcile, not permission to discard the claim and start another attempt.
1180
+ """
1181
+ items = currency.get("items") or {}
1182
+ current_key = currency.get("current_key")
1183
+ claimed = []
1184
+ current = items.get(current_key)
1185
+ if isinstance(current, dict) and current.get("disposition") == CURRENCY_CLAIMED:
1186
+ claimed.append((current_key, current))
1187
+ claimed.extend(
1188
+ (key, item) for key, item in items.items()
1189
+ if key != current_key and isinstance(item, dict)
1190
+ and item.get("disposition") == CURRENCY_CLAIMED
1191
+ )
1192
+ return claimed
1193
+
1194
+
1195
+ def _prepare_claimed_currency_item(state, item, cur=None, head=None):
1196
+ invocation_id = state.get("invocation_id")
1197
+ already_handled_here = (
1198
+ item.get("claimed_invocation_id") == invocation_id
1199
+ or item.get("reconciled_invocation_id") == invocation_id
1200
+ )
1201
+ base = (cur or {}).get("base") or {}
1202
+ async_evidence_moved = (
1203
+ item.get("recovery_state") in (
1204
+ CURRENCY_OUTCOME_MUTATION_OBSERVED, CURRENCY_OUTCOME_AMBIGUOUS)
1205
+ and ((head and head != item.get("head_sha"))
1206
+ or (base.get("oid") and base.get("oid") != item.get("base_oid")))
1207
+ )
1208
+ item["attention"] = (CURRENCY_ATTENTION_RECONCILE
1209
+ if async_evidence_moved or not already_handled_here else None)
1210
+ item["reconciliation_only"] = True
1211
+ return item
1212
+
1213
+
1214
+ def _apply_stale_merge_computation(state, cur, head, now):
1215
+ """Bounded degrade for a `race` whose only mismatch is GitHub's stale test merge. Tracks the
1216
+ first time the same (head, live base) pair was seen in that state; after
1217
+ STALE_MERGE_COMPUTATION_SECONDS the identity becomes `stale-computation` and the base carries
1218
+ `merge_computation_stale: true` so the report can disclose it. Any head/base movement resets."""
1219
+ base = cur.get("base") if isinstance(cur.get("base"), dict) else None
1220
+ if not base:
1221
+ state["stale_merge_computation"] = None
1222
+ return
1223
+ live = base.get("oid")
1224
+ candidate = (base.get("identity") == BASE_REF_RACE
1225
+ and base.get("stale_computation_candidate")
1226
+ and _valid_oid(live) and _valid_oid(head))
1227
+ if not candidate:
1228
+ state["stale_merge_computation"] = None
1229
+ return
1230
+ key = f"{str(head).lower()}:{str(live).lower()}"
1231
+ tracked = state.get("stale_merge_computation")
1232
+ if not isinstance(tracked, dict) or tracked.get("key") != key:
1233
+ tracked = {"key": key, "first_seen_at": _iso(now)}
1234
+ state["stale_merge_computation"] = tracked
1235
+ if _elapsed(tracked.get("first_seen_at"), now) >= STALE_MERGE_COMPUTATION_SECONDS:
1236
+ base["identity"] = BASE_REF_STALE_COMPUTATION
1237
+ base["merge_computation_stale"] = True
1238
+
1239
+
1240
+ def _detect_unrequested_base_merge(state, cur, head, head_changed, now):
1241
+ """Head-scoped fact: the current head is a two-parent merge of the base tip that no claimed
1242
+ branch_currency item produced. Ordinary base movement with CLEAN never yields an item, so a
1243
+ base-into-head merge on such a PR is a defect the loop must report, not maintenance. Persisted
1244
+ on the head that introduced it and cleared by the next head change; a legitimate DIRTY repair
1245
+ (mutation_consumed on a claimed item whose head is the merge's other parent) is excluded. A head
1246
+ whose parents could not be observed stays pending and is re-evaluated every snapshot. The head
1247
+ seen on the first-ever snapshot of a state dir is the watch's baseline, not a candidate: the
1248
+ loop has no evidence about who produced it (a user merging main by hand before invoking is
1249
+ ordinary), and state persists across re-invocations, so only heads that appear during the
1250
+ watch are classified."""
1251
+ # Must run before diff() overwrites state["base"] with the current observation.
1252
+ prior_base = state.get("base") if isinstance(state.get("base"), dict) else {}
1253
+ persisted = state.get("unrequested_base_merge")
1254
+ pending = state.get("unrequested_base_merge_pending")
1255
+ should_evaluate = head_changed or pending == head
1256
+ if not should_evaluate:
1257
+ return persisted if isinstance(persisted, dict) and persisted.get("head") == head else None
1258
+
1259
+ state["unrequested_base_merge"] = None # cleared unless proven below
1260
+ if "head_parents" not in cur:
1261
+ # Pre-detector fixture or persisted snapshot: no parent evidence exists to wait for.
1262
+ state["unrequested_base_merge_pending"] = None
1263
+ return None
1264
+ parents = cur.get("head_parents")
1265
+ if parents is None:
1266
+ state["unrequested_base_merge_pending"] = head
1267
+ return None
1268
+ state["unrequested_base_merge_pending"] = None
1269
+ if not isinstance(parents, list) or len(parents) != 2:
1270
+ return None
1271
+ base = cur.get("base") if isinstance(cur.get("base"), dict) else {}
1272
+ base_oids = {
1273
+ str(o).lower() for o in (
1274
+ base.get("oid"), base.get("graphql_oid"), base.get("historical_oid"),
1275
+ prior_base.get("oid"), prior_base.get("graphql_oid"))
1276
+ if _valid_oid(o)
1277
+ }
1278
+ base_parent = next((p for p in parents if p in base_oids), None)
1279
+ if base_parent is None:
1280
+ return None
1281
+ # Only a consumed mutation that produced THIS merge exempts it: the claimed item must name both
1282
+ # parents — its head is the merge's other parent and its base is the base parent. A confirmed
1283
+ # item for an earlier head, or for an older base, is not evidence.
1284
+ other_parents = {p for p in parents if p != base_parent}
1285
+ for item in (_load_branch_currency_state(state).get("items") or {}).values():
1286
+ if (isinstance(item, dict) and item.get("mutation_consumed")
1287
+ and str(item.get("head_sha") or "").lower() in other_parents
1288
+ and str(item.get("base_oid") or "").lower() == base_parent):
1289
+ return None
1290
+ fact = {"head": head, "base_parent": base_parent, "observed_at": _iso(now)}
1291
+ state["unrequested_base_merge"] = fact
1292
+ return fact
1293
+
1294
+
1295
+ def _prepare_open_currency_item(state, currency, key, item, now):
1296
+ parks = currency.get("semantic_parks") or {}
1297
+ answer_required = _currency_mutation_requires_answer(state, key, item)
1298
+ inspection_required = (item.get("status") == "DIRTY"
1299
+ and bool(parks)
1300
+ and item.get("inspection_result") != "changed"
1301
+ and not _answered_human_source_matches(
1302
+ state, "currency", key, _currency_source_observation(item)))
1303
+ item["inspection_required"] = inspection_required
1304
+ retry_wait_seconds = 0
1305
+ retry_not_before = item.get("retry_not_before")
1306
+ if retry_not_before:
1307
+ try:
1308
+ retry_wait_seconds = max(
1309
+ 0, int((_parse_iso8601(retry_not_before) - now).total_seconds()))
1310
+ except (ValueError, TypeError):
1311
+ retry_wait_seconds = 0
1312
+ item["retry_wait_seconds"] = retry_wait_seconds
1313
+ if answer_required:
1314
+ item["attention"] = CURRENCY_ATTENTION_DECIDE
1315
+ elif inspection_required:
1316
+ item["attention"] = CURRENCY_ATTENTION_INSPECT
1317
+ elif retry_wait_seconds > 0:
1318
+ item["attention"] = None
1319
+ else:
1320
+ item["attention"] = CURRENCY_ATTENTION_CLAIM
1321
+ item["reconciliation_only"] = False
1322
+
1323
+
1324
+ def _apply_branch_currency(state, cur, head, head_changed, now):
1325
+ currency = _load_branch_currency_state(state)
1326
+ carried_claims = _claimed_currency_items(currency)
1327
+ carried_semantic_parks = dict(currency.get("semantic_parks") or {})
1328
+ if head_changed or (currency.get("head_sha") not in (None, head)):
1329
+ currency = _empty_branch_currency_state()
1330
+ currency["items"] = dict(carried_claims)
1331
+ currency["semantic_parks"] = carried_semantic_parks
1332
+ state["branch_currency_state"] = currency
1333
+ currency["head_sha"] = head
1334
+
1335
+ # An unresolved claim owns the branch-currency lane until it is explicitly reconciled. A base
1336
+ # move or expected head move may be the mutation's result, so a new observation must not replace
1337
+ # the old exact key or make the PR look clear in the meantime.
1338
+ claimed_items = _claimed_currency_items(currency)
1339
+ if claimed_items:
1340
+ key, item = claimed_items[0]
1341
+ currency["current_key"] = key
1342
+ return _prepare_claimed_currency_item(state, item, cur, head)
1343
+
1344
+ observation = _branch_currency_observation(cur, head)
1345
+ if observation is None:
1346
+ currency["current_key"] = None
1347
+ return None
1348
+
1349
+ key = observation["key"]
1350
+ items = currency["items"]
1351
+ item = items.get(key)
1352
+ if not isinstance(item, dict):
1353
+ item = {**observation, "disposition": DISPOSITION_OPEN}
1354
+ else:
1355
+ # Refresh non-identity facts without changing an invocation-fenced disposition.
1356
+ item.update(observation)
1357
+ item["host_branch_update_capability"] = cur.get(
1358
+ "host_branch_update_capability", "unknown")
1359
+ parks = currency.get("semantic_parks") or {}
1360
+ item["parked_semantic_fingerprints"] = sorted(parks)
1361
+ item.setdefault("retry_count", 0)
1362
+ item.setdefault("mutation_consumed", False)
1363
+ item.setdefault("mutation_requires_answer", False)
1364
+ disposition = item.get("disposition", DISPOSITION_OPEN)
1365
+ if disposition == DISPOSITION_OPEN:
1366
+ _prepare_open_currency_item(state, currency, key, item, now)
1367
+ elif disposition == CURRENCY_CLAIMED:
1368
+ _prepare_claimed_currency_item(state, item, cur, head)
1369
+ else:
1370
+ item["attention"] = None
1371
+ item["reconciliation_only"] = False
1372
+ items[key] = item
1373
+ currency["current_key"] = key
1374
+
1375
+ # Old observations are useful only when they carry an unresolved mutation claim.
1376
+ retained = {item_key: value for item_key, value in items.items()
1377
+ if item_key == key
1378
+ or value.get("disposition") == CURRENCY_CLAIMED}
1379
+ currency["items"] = retained
1380
+ return item
1381
+
1382
+
1383
+ def _empty_trajectory():
1384
+ """Deterministic cross-tick facts babysit hands the leaves (facts, not judgment):
1385
+ babysit ships the trajectory, the leaf decides whether it means non-convergence."""
1386
+ return {
1387
+ "check_history": {}, # check key -> {state, last_head, recur, seen_tick}
1388
+ "seen_threads": {}, # unresolved thread id -> first-seen tick
1389
+ "unresolved_series": [], # unresolved-thread count per tick (last 6)
1390
+ "stream_series": [], # single-stream activity per tick (last 8)
1391
+ "problem_keys": [], # last tick's failing checks + non-parked threads (progress detection)
1392
+ "min_open_problems": None, # lowest total open-problem count seen
1393
+ "heads_since_progress": 0, # head changes since progress (a new low OR something cleared)
1394
+ "last_head": None, # head as of the last AGENT tick — hsp counts moves between ticks,
1395
+ # NOT poll-observed head moves (state["head_sha"] advances on polls)
1396
+ "invariant_heads": {}, # resolver-supplied invariant_key -> unique heads it was marked on
1397
+ }
1398
+
1399
+
1400
+ def _load_trajectory(state):
1401
+ """Load the trajectory, tolerating a partial or non-dict value from an older on-disk
1402
+ state.json (persisted in /tmp across script versions) — backfill missing keys so a new
1403
+ field never KeyErrors an old state, and a null/garbage value never crashes."""
1404
+ tj = state.get("trajectory")
1405
+ if not isinstance(tj, dict):
1406
+ tj = {}
1407
+ for key, value in _empty_trajectory().items():
1408
+ tj.setdefault(key, value)
1409
+ state["trajectory"] = tj
1410
+ return tj
1411
+
1412
+
1413
+ _INVARIANT_KEY_RE = re.compile(r"^[A-Za-z0-9._:-]{1,120}$")
1414
+
1415
+
1416
+ def _record_invariant_round(state, key, head):
1417
+ """Count a resolver-supplied invariant against unique heads. Never infer keys."""
1418
+ if not key:
1419
+ return
1420
+ if not _INVARIANT_KEY_RE.match(key):
1421
+ raise SystemExit("--invariant-key must be 1-120 chars of A-Za-z0-9._:-")
1422
+ if not head:
1423
+ return
1424
+ tj = _load_trajectory(state)
1425
+ heads = tj.setdefault("invariant_heads", {})
1426
+ seen = heads.setdefault(key, [])
1427
+ if not isinstance(seen, list):
1428
+ seen = []
1429
+ heads[key] = seen
1430
+ if head not in seen:
1431
+ seen.append(head)
1432
+
1433
+
1434
+ def _invariant_rounds_view(tj):
1435
+ """Public trajectory view: resolver keys counted per unique head."""
1436
+ items = []
1437
+ for key, heads in sorted((tj.get("invariant_heads") or {}).items()):
1438
+ if isinstance(heads, list):
1439
+ items.append({"key": key, "rounds": len(heads)})
1440
+ return items
1441
+
1442
+
1443
+ def _push_bounded(lst, item, cap):
1444
+ """Append to a sliding window that keeps only the last `cap` items."""
1445
+ lst.append(item)
1446
+ del lst[:-cap]
1447
+
1448
+
1449
+ def _stream_alternations(series):
1450
+ """Count flips between ci-active and review-active ticks — the cross-stream churn signal."""
1451
+ flips = 0
1452
+ prev = None
1453
+ for s in series:
1454
+ if prev is not None and s != prev:
1455
+ flips += 1
1456
+ prev = s
1457
+ return flips
1458
+
1459
+
1460
+ def _trend(series):
1461
+ if len(series) < 3:
1462
+ return "flat"
1463
+ if series[-1] > series[0]:
1464
+ return "rising"
1465
+ if series[-1] < series[0]:
1466
+ return "falling"
1467
+ return "flat"
1468
+
1469
+
1470
+ def _record_check_history(state, head, new_checks):
1471
+ """Record CI fail->clear->fail recurrence transitions. Runs on EVERY snapshot — agent ticks AND
1472
+ watch polls — so a CLEAR (or FAIL) observed only between agent ticks is not lost, which would
1473
+ otherwise make the ping-pong recurrence trigger under-fire under the self-sustaining watch.
1474
+ Idempotent per transition: once a check is FAILING, re-observing FAIL does not re-increment."""
1475
+ tj = _load_trajectory(state)
1476
+ tick = state.get("tick", 0)
1477
+ hist = tj["check_history"]
1478
+ for key, c in new_checks.items():
1479
+ h = hist.setdefault(key, {"state": CHECK_UNKNOWN, "last_head": None, "recur": 0, "seen_tick": tick})
1480
+ h["seen_tick"] = tick
1481
+ if c["conclusion"] in FAILING:
1482
+ if h["state"] == CHECK_CLEAR and h["last_head"] != head: # fail after a clear on a new head
1483
+ h["recur"] += 1
1484
+ h["state"] = CHECK_FAILING
1485
+ h["last_head"] = head
1486
+ elif c["status"] == "COMPLETED": # observed non-failing terminal — a genuine clear
1487
+ h["state"] = CHECK_CLEAR
1488
+ h["last_head"] = head
1489
+ # IN_PROGRESS/QUEUED: leave prior state untouched (not yet a clear)
1490
+ # Evict entries unseen for TTL agent-ticks (polls don't advance the tick) — bounds growth without
1491
+ # erasing a check that was briefly absent (a one-tick gap must not lose real recurrence history).
1492
+ tj["check_history"] = {k: v for k, v in hist.items() if tick - v.get("seen_tick", tick) <= CHECK_HISTORY_TTL}
1493
+
1494
+
1495
+ def _update_trajectory(state, head, new_checks, new_threads, new_feedback, actionable,
1496
+ decision_coverage=None):
1497
+ """Maintain and emit the deterministic trajectory. Coarse by design: check-name-level
1498
+ recurrence, backlog trend, cross-stream alternation, no-progress heads. Fine, invariant-
1499
+ level judgment (log signatures, nit root-clustering) is the leaf's job, not this script's.
1500
+ `actionable` is the `{ci, threads, comments}` set diff() also returns. Non-thread feedback
1501
+ (top-level comments + review bodies) counts as review-stream activity and as an open problem
1502
+ for the stall signal, but the thread-named backlog fields stay scoped to inline threads."""
1503
+ tj = _load_trajectory(state)
1504
+
1505
+ # --- CI recurrence (fail->clear->fail on a *different* head): recorded on EVERY observation
1506
+ # (agent ticks AND watch polls, via _record_check_history in diff) so a clear seen only between
1507
+ # agent ticks is not lost. Here we just READ the accumulated history for the trigger. recur_max
1508
+ # reflects only checks present this tick, so a stale key can't keep it elevated. ---
1509
+ hist = tj["check_history"]
1510
+ recur_max = max((hist[k]["recur"] for k in new_checks if k in hist), default=0)
1511
+ recurring = [{"key": k, "recur": hist[k]["recur"]} for k in new_checks if k in hist and hist[k]["recur"] > 0]
1512
+
1513
+ # --- Review-thread backlog: trend of unresolved-thread count + genuinely new threads this
1514
+ # tick. Scoped to inline threads (non-thread feedback is captured in the total-problem stall
1515
+ # signal below), so these thread-named fields stay accurate. ---
1516
+ seen = tj.get("seen_threads", {})
1517
+ new_arrivals = [tid for tid in new_threads if tid not in seen]
1518
+ tj["seen_threads"] = {tid: seen.get(tid, state.get("tick", 0)) for tid in new_threads}
1519
+ _push_bounded(tj["unresolved_series"], len(new_threads), 6)
1520
+
1521
+ # --- Cross-stream churn: alternation between ci-only and review-only active ticks.
1522
+ # Review is active when either threads OR non-thread feedback is actionable. ---
1523
+ review_active = bool(actionable["threads"] or actionable.get("comments"))
1524
+ if actionable["ci"] and not review_active:
1525
+ active = STREAM_CI
1526
+ elif review_active and not actionable["ci"]:
1527
+ active = STREAM_REVIEW
1528
+ else:
1529
+ active = None # both or neither — not a single-stream tick, don't record
1530
+ if active:
1531
+ _push_bounded(tj["stream_series"], active, 8)
1532
+
1533
+ # --- No-progress heads: measured from TOTAL open problems (failing checks + non-parked
1534
+ # unresolved threads + non-parked non-thread feedback), NOT the post-claim `actionable` set —
1535
+ # marking items dispatched shrinks
1536
+ # actionable and would fake progress. Reset the counter when the head moves and either the
1537
+ # total set a new low OR a previously-failing item cleared: progressive migration (A cleared
1538
+ # while B appears) is progress, not a stall, so it must not accrue heads_since_progress. ---
1539
+ # Only genuinely-OPEN items are unresolved work: a `dispatched` item is handled (a top-level
1540
+ # comment never drops out of the fetch, so counting it would keep heads_since_progress climbing
1541
+ # forever and falsely trip non-convergence on unrelated later work), and `needs-human` is parked.
1542
+ decision_coverage = decision_coverage or set()
1543
+ problem_keys = {f"c:{k}" for k, c in new_checks.items()
1544
+ if c["conclusion"] in FAILING and ("check", k) not in decision_coverage}
1545
+ problem_keys |= {f"t:{tid}" for tid, t in new_threads.items()
1546
+ if t.get("disposition") == DISPOSITION_OPEN
1547
+ and ("thread", tid) not in decision_coverage}
1548
+ problem_keys |= {f"m:{fid}" for fid, f in new_feedback.items()
1549
+ if f.get("disposition") == DISPOSITION_OPEN
1550
+ and (f.get("kind", "comment"), fid) not in decision_coverage}
1551
+ cleared_something = bool(set(tj.get("problem_keys", [])) - problem_keys)
1552
+ open_problems = len(problem_keys)
1553
+ minp = tj.get("min_open_problems")
1554
+ new_low = minp is None or open_problems < minp
1555
+ if new_low:
1556
+ tj["min_open_problems"] = open_problems
1557
+ # heads_since_progress counts head moves BETWEEN AGENT TICKS (tj["last_head"]), not poll-observed
1558
+ # moves — a watch poll advances state["head_sha"], so a plain head_changed would read False at the
1559
+ # agent's tick and starve this stall signal under the default self-sustaining watch.
1560
+ traj_head_moved = tj.get("last_head") is not None and head != tj.get("last_head")
1561
+ if new_low or cleared_something:
1562
+ tj["heads_since_progress"] = 0
1563
+ elif traj_head_moved:
1564
+ tj["heads_since_progress"] = tj.get("heads_since_progress", 0) + 1
1565
+ tj["problem_keys"] = sorted(problem_keys)
1566
+ tj["last_head"] = head
1567
+
1568
+ return {
1569
+ "recurring_checks": recurring,
1570
+ "check_recur_max": recur_max,
1571
+ "unresolved_threads": len(new_threads),
1572
+ "unresolved_series": list(tj["unresolved_series"]),
1573
+ "unresolved_trend": _trend(tj["unresolved_series"]),
1574
+ "new_threads_this_tick": len(new_arrivals),
1575
+ "stream_alternations": _stream_alternations(tj["stream_series"]),
1576
+ "heads_since_progress": tj["heads_since_progress"],
1577
+ "invariant_rounds": _invariant_rounds_view(tj),
1578
+ }
1579
+
1580
+
1581
+ def _apply_dispositions(items, id_key, prior, identity_fn=None,
1582
+ reactivate_dispositions=(DISPOSITION_DISPATCHED,)):
1583
+ """Claim->act->confirm dedup for a review stream: an item stays actionable until `mark`
1584
+ records a non-open disposition (persisted in prior[id]['disposition']). Returns
1585
+ (persisted_by_id, actionable_list, 0).
1586
+
1587
+ When identity_fn is given, an item whose disposition is in `reactivate_dispositions` is
1588
+ *reactivated* — set back to open and re-actionized — once its last-comment / edit identity moves
1589
+ past the one we acted on. That acted-on identity is captured lazily on the first tick after our
1590
+ own reply, so the reply becomes the baseline and later reviewer activity reopens the item. This
1591
+ stops a dispatched-but-unresolved thread with fresh activity from being silenced.
1592
+
1593
+ The feedback stream excludes DISPATCHED from `reactivate_dispositions` deliberately: status
1594
+ bots rewrite their comment bodies on every push, so edit-keyed reactivation re-actionized
1595
+ handled bot comments forever and merge-ready could never fire (#1309). A brand-new comment is
1596
+ a new id and is always actionable. Human decisions are a separate coverage layer below; source
1597
+ dispositions remain ordinary open/dispatched facts."""
1598
+ persisted, actionable = {}, []
1599
+ for it in items:
1600
+ iid = it.get(id_key)
1601
+ if not iid:
1602
+ continue
1603
+ pri = prior.get(iid, {})
1604
+ disposition = pri.get("disposition", DISPOSITION_OPEN)
1605
+ acted_identity = pri.get("acted_identity")
1606
+ if disposition in reactivate_dispositions and identity_fn is not None:
1607
+ current_identity = identity_fn(it)
1608
+ if acted_identity is None:
1609
+ acted_identity = current_identity # first post-action observation: adopt as baseline
1610
+ elif current_identity != acted_identity:
1611
+ disposition = DISPOSITION_OPEN # a later human/reviewer reply past our baseline -> reactivate
1612
+ acted_identity = None
1613
+ if disposition == DISPOSITION_OPEN:
1614
+ actionable.append(it)
1615
+ rec = {**it, "disposition": disposition}
1616
+ if acted_identity is not None:
1617
+ rec["acted_identity"] = acted_identity
1618
+ persisted[iid] = rec
1619
+ return persisted, actionable, 0
1620
+
1621
+
1622
+ def _needs_human_residual_error(residual):
1623
+ """Return why a residual is malformed, or None when the full contract holds."""
1624
+ if not isinstance(residual, dict) or residual.get("type") != "needs-human":
1625
+ return "must be one typed needs-human object"
1626
+ sources = residual.get("sources")
1627
+ if not isinstance(sources, list) or not sources:
1628
+ return "sources must be a non-empty list"
1629
+ seen_sources = set()
1630
+ for source in sources:
1631
+ if not isinstance(source, dict):
1632
+ return "every source must be an object"
1633
+ source_id, kind = source.get("id"), source.get("kind")
1634
+ if not isinstance(source_id, str) or not source_id.strip():
1635
+ return "every source id must be a non-empty string"
1636
+ if kind not in NEEDS_HUMAN_SOURCE_KINDS:
1637
+ return "every source kind must be thread, comment, review, check, or currency"
1638
+ identity = (kind, source_id)
1639
+ if identity in seen_sources:
1640
+ return "sources must be unique"
1641
+ seen_sources.add(identity)
1642
+
1643
+ context = residual.get("decision_context")
1644
+ if not isinstance(context, dict):
1645
+ return "decision_context must be an object"
1646
+ for field in ("quoted_feedback", "investigation", "decision_reason"):
1647
+ if not isinstance(context.get(field), str) or not context[field].strip():
1648
+ return f"decision_context.{field} must be a non-empty string"
1649
+ options = context.get("options")
1650
+ if not isinstance(options, list) or not options:
1651
+ return "decision_context.options must be a non-empty list"
1652
+ for option in options:
1653
+ if not isinstance(option, dict):
1654
+ return "every decision_context option must be an object"
1655
+ for field in ("option", "tradeoff"):
1656
+ if not isinstance(option.get(field), str) or not option[field].strip():
1657
+ return f"every decision_context option.{field} must be a non-empty string"
1658
+ if "recommendation" not in context:
1659
+ return "decision_context.recommendation is required"
1660
+ recommendation = context.get("recommendation")
1661
+ if recommendation is not None and (
1662
+ not isinstance(recommendation, str) or not recommendation.strip()):
1663
+ return "decision_context.recommendation must be null or a non-empty string"
1664
+
1665
+ thread_urls = residual.get("thread_urls")
1666
+ if not isinstance(thread_urls, list) or any(
1667
+ not isinstance(url, str) or not url.strip() for url in thread_urls):
1668
+ return "thread_urls must be a list of non-empty strings"
1669
+ thread_sources = sum(source["kind"] == "thread" for source in sources)
1670
+ if len(set(thread_urls)) != len(thread_urls) or len(thread_urls) != thread_sources:
1671
+ return "thread_urls must contain exactly one unique URL for every thread source"
1672
+ return None
1673
+
1674
+
1675
+ def _residual_identity(residual):
1676
+ return json.dumps(residual, sort_keys=True, separators=(",", ":"))
1677
+
1678
+
1679
+ def _source_identity(source):
1680
+ return source.get("kind"), source.get("id")
1681
+
1682
+
1683
+ def _check_observation_identity(check):
1684
+ if not isinstance(check, dict):
1685
+ return None
1686
+ return {
1687
+ "head_sha": check.get("head_sha"),
1688
+ "conclusion": check.get("conclusion"),
1689
+ "details_url": check.get("details_url"),
1690
+ "created_at": check.get("created_at"),
1691
+ "started_at": check.get("started_at"),
1692
+ "completed_at": check.get("completed_at"),
1693
+ }
1694
+
1695
+
1696
+ def _currency_source_observation(item):
1697
+ if not isinstance(item, dict):
1698
+ return None
1699
+ observation = {
1700
+ "key": item.get("key"),
1701
+ "status": item.get("status"),
1702
+ "semantic_conflict_fingerprint": item.get("semantic_conflict_fingerprint"),
1703
+ "recovery_state": item.get("recovery_state"),
1704
+ "mutation_requires_answer": bool(item.get("mutation_requires_answer", False)),
1705
+ }
1706
+ if item.get("status") == "BEHIND" and item.get("recovery_state") is None:
1707
+ observation["host_branch_update_capability"] = item.get(
1708
+ "host_branch_update_capability")
1709
+ return observation
1710
+
1711
+
1712
+ def _current_source_observation(state, kind, source_id, threads, feedback, checks):
1713
+ if kind == "thread":
1714
+ record = threads.get(source_id)
1715
+ return ([record.get("last_comment_id"), record.get("last_comment_at")]
1716
+ if isinstance(record, dict) else None)
1717
+ if kind in ("comment", "review"):
1718
+ record = feedback.get(source_id)
1719
+ if not isinstance(record, dict) or record.get("kind") != kind:
1720
+ return None
1721
+ return [record.get("edit_id")]
1722
+ if kind == "check":
1723
+ record = checks.get(source_id)
1724
+ if not isinstance(record, dict) or record.get("conclusion") not in FAILING:
1725
+ return None
1726
+ return _check_observation_identity(record)
1727
+ if kind == "currency":
1728
+ currency = _load_branch_currency_state(state)
1729
+ if currency.get("current_key") != source_id:
1730
+ return None
1731
+ return _currency_source_observation(currency.get("items", {}).get(source_id))
1732
+ return None
1733
+
1734
+
1735
+ def _decision_id(residual, sources):
1736
+ payload = json.dumps(
1737
+ {"residual": residual, "sources": sources}, sort_keys=True, separators=(",", ":"))
1738
+ return f"decision:{hashlib.sha256(payload.encode('utf-8')).hexdigest()}"
1739
+
1740
+
1741
+ def _decision_record(residual, sources, created_at=None):
1742
+ return {
1743
+ "id": _decision_id(residual, sources),
1744
+ "residual": json.loads(json.dumps(residual)),
1745
+ "sources": json.loads(json.dumps(sources)),
1746
+ "created_at": created_at,
1747
+ }
1748
+
1749
+
1750
+ def _install_human_decisions(state, decisions):
1751
+ """Install canonical decisions while retaining currency mutation safety independently."""
1752
+ currency = _load_branch_currency_state(state)
1753
+ items = currency.get("items", {})
1754
+ for decision in decisions:
1755
+ if not isinstance(decision, dict):
1756
+ continue
1757
+ sources = decision.get("sources")
1758
+ if not isinstance(sources, list):
1759
+ continue
1760
+ for source in sources:
1761
+ if not isinstance(source, dict) or source.get("kind") != "currency":
1762
+ continue
1763
+ item = items.get(source.get("id"))
1764
+ if isinstance(item, dict):
1765
+ item["mutation_requires_answer"] = True
1766
+ observation = source.get("observation")
1767
+ if isinstance(observation, dict):
1768
+ observation["mutation_requires_answer"] = True
1769
+ if isinstance(decision.get("residual"), dict):
1770
+ decision["id"] = _decision_id(decision["residual"], sources)
1771
+ state["human_decisions"] = decisions
1772
+
1773
+
1774
+ def _decision_source_matches(source, kind, source_id, observation):
1775
+ return (isinstance(source, dict) and source.get("kind") == kind
1776
+ and source.get("id") == source_id and source.get("observation") == observation)
1777
+
1778
+
1779
+ def _answered_human_source_matches(state, kind, source_id, observation):
1780
+ return any(
1781
+ _decision_source_matches(source, kind, source_id, observation)
1782
+ for decision in state.get("answered_human_decisions") or []
1783
+ if isinstance(decision, dict)
1784
+ for source in decision.get("sources") or []
1785
+ )
1786
+
1787
+
1788
+ def _currency_mutation_requires_answer(state, source_id, item):
1789
+ observation = _currency_source_observation(item)
1790
+ return (bool(item.get("mutation_requires_answer"))
1791
+ and not _answered_human_source_matches(
1792
+ state, "currency", source_id, observation))
1793
+
1794
+
1795
+ def _source_is_actionable(state, kind, source_id, threads, feedback, checks):
1796
+ if kind == "thread":
1797
+ record = threads.get(source_id)
1798
+ return isinstance(record, dict) and record.get("disposition") == DISPOSITION_OPEN
1799
+ if kind in ("comment", "review"):
1800
+ record = feedback.get(source_id)
1801
+ return (isinstance(record, dict) and record.get("kind") == kind
1802
+ and record.get("disposition") == DISPOSITION_OPEN)
1803
+ if kind == "check":
1804
+ record = checks.get(source_id)
1805
+ head = state.get("head_sha")
1806
+ return (isinstance(record, dict) and record.get("conclusion") in FAILING
1807
+ and source_id not in state.get("ci_dispatched", {}).get(head, []))
1808
+ if kind == "currency":
1809
+ currency = _load_branch_currency_state(state)
1810
+ record = currency.get("items", {}).get(source_id)
1811
+ return (currency.get("current_key") == source_id and isinstance(record, dict)
1812
+ and record.get("disposition") in (DISPOSITION_OPEN, CURRENCY_CLAIMED))
1813
+ return False
1814
+
1815
+
1816
+ def _source_can_enter_human_decision(state, kind, source_id, threads, feedback, checks):
1817
+ """Accept exact current evidence even when ordinary dispatch already suppresses it."""
1818
+ if _current_source_observation(state, kind, source_id, threads, feedback, checks) is None:
1819
+ return False
1820
+ if kind != "currency":
1821
+ return True
1822
+ currency = _load_branch_currency_state(state)
1823
+ record = currency.get("items", {}).get(source_id)
1824
+ return (currency.get("current_key") == source_id and isinstance(record, dict)
1825
+ and record.get("disposition") in (DISPOSITION_OPEN, CURRENCY_CLAIMED))
1826
+
1827
+
1828
+ def _release_ordinary_suppression_for_decision(state, sources, threads, feedback):
1829
+ """Make canonical decision coverage the sole suppression owner for review and CI."""
1830
+ head = state.get("head_sha")
1831
+ dispatched_checks = state.get("ci_dispatched", {}).get(head)
1832
+ for source in sources:
1833
+ kind, source_id = source.get("kind"), source.get("id")
1834
+ record = (threads.get(source_id) if kind == "thread"
1835
+ else feedback.get(source_id) if kind in ("comment", "review") else None)
1836
+ if isinstance(record, dict):
1837
+ record["disposition"] = DISPOSITION_OPEN
1838
+ record.pop("acted_identity", None)
1839
+ elif kind == "check" and isinstance(dispatched_checks, list):
1840
+ state["ci_dispatched"][head] = [
1841
+ key for key in state["ci_dispatched"][head] if key != source_id]
1842
+
1843
+
1844
+ def _migrate_legacy_check_observation(state, source_id, legacy_observations, checks):
1845
+ legacy = legacy_observations.get(source_id)
1846
+ current = _current_source_observation(state, "check", source_id, {}, {}, checks)
1847
+ if (not isinstance(legacy, dict) or not isinstance(current, dict)
1848
+ or current.get("head_sha") != state.get("head_sha")
1849
+ or any(current.get(field) != value for field, value in legacy.items())):
1850
+ return None
1851
+ return current
1852
+
1853
+
1854
+ def _retire_legacy_check_suppression(state, residual):
1855
+ if not isinstance(residual, dict) or not isinstance(residual.get("sources"), list):
1856
+ return
1857
+ head = state.get("head_sha")
1858
+ dispatched = state.get("ci_dispatched", {}).get(head)
1859
+ if not isinstance(dispatched, list):
1860
+ return
1861
+ legacy_check_ids = {
1862
+ source.get("id") for source in residual["sources"]
1863
+ if isinstance(source, dict) and source.get("kind") == "check"
1864
+ }
1865
+ state["ci_dispatched"][head] = [
1866
+ key for key in dispatched if key not in legacy_check_ids]
1867
+
1868
+
1869
+ def _legacy_decision_records(state, threads, feedback, checks, now):
1870
+ """Replace legacy suppression with exact current decisions; incomplete evidence fails open."""
1871
+ candidates = list(state.get("needs_human_residuals") or [])
1872
+ candidates.extend(
1873
+ record.get("needs_human_residual")
1874
+ for record in list(threads.values()) + list(feedback.values())
1875
+ if isinstance(record.get("needs_human_residual"), dict)
1876
+ )
1877
+ legacy_check_observations = state.get("needs_human_check_observations") or {}
1878
+ records, seen = [], set()
1879
+ for residual in candidates:
1880
+ _retire_legacy_check_suppression(state, residual)
1881
+ if _needs_human_residual_error(residual):
1882
+ continue
1883
+ identity = _residual_identity(residual)
1884
+ if identity in seen:
1885
+ continue
1886
+ seen.add(identity)
1887
+ sources = []
1888
+ for source in residual["sources"]:
1889
+ kind, source_id = _source_identity(source)
1890
+ observation = None
1891
+ record = (threads.get(source_id) if kind == "thread"
1892
+ else feedback.get(source_id) if kind in ("comment", "review") else None)
1893
+ if isinstance(record, dict) and record.get("acted_identity") is not None:
1894
+ observation = record.get("acted_identity")
1895
+ elif kind == "check":
1896
+ observation = _migrate_legacy_check_observation(
1897
+ state, source_id, legacy_check_observations, checks)
1898
+ else:
1899
+ observation = _current_source_observation(
1900
+ state, kind, source_id, threads, feedback, checks)
1901
+ if observation is None:
1902
+ sources = []
1903
+ break
1904
+ sources.append({"kind": kind, "id": source_id, "observation": observation})
1905
+ if sources:
1906
+ records.append(_decision_record(residual, sources, _iso(now)))
1907
+ return records
1908
+
1909
+
1910
+ def _legacy_answered_decision_records(state, threads, feedback, checks):
1911
+ """Preserve only the exact source evidence carried by a legacy answer owner."""
1912
+ records, seen = [], set()
1913
+ currency = _load_branch_currency_state(state)
1914
+ for source_id, item in currency.get("items", {}).items():
1915
+ answered = item.get("answered_decision")
1916
+ if not isinstance(answered, dict):
1917
+ continue
1918
+ residual, answer = answered.get("residual"), answered.get("answer")
1919
+ _retire_legacy_check_suppression(state, residual)
1920
+ if _needs_human_residual_error(residual) or not isinstance(answer, str) or not answer.strip():
1921
+ continue
1922
+ owns_residual = any(
1923
+ _source_identity(source) == ("currency", source_id)
1924
+ for source in residual["sources"])
1925
+ observation = _current_source_observation(
1926
+ state, "currency", source_id, threads, feedback, checks)
1927
+ if not owns_residual or observation is None:
1928
+ continue
1929
+ sources = [{"kind": "currency", "id": source_id, "observation": observation}]
1930
+ record = {
1931
+ **_decision_record(residual, sources, answered.get("recorded_at")),
1932
+ "answer": answer.strip(),
1933
+ "answered_at": answered.get("recorded_at"),
1934
+ }
1935
+ if record["id"] not in seen:
1936
+ seen.add(record["id"])
1937
+ records.append(record)
1938
+ return records
1939
+
1940
+
1941
+ def _normalize_legacy_source_state(state, threads, feedback):
1942
+ for records in (threads, feedback):
1943
+ for record in records.values():
1944
+ if record.get("disposition") == DISPOSITION_NEEDS_HUMAN:
1945
+ record["disposition"] = DISPOSITION_OPEN
1946
+ record.pop("needs_human_residual", None)
1947
+ if record.get("disposition") == DISPOSITION_OPEN:
1948
+ record.pop("acted_identity", None)
1949
+ currency = _load_branch_currency_state(state)
1950
+ for item in currency.get("items", {}).values():
1951
+ if item.get("disposition") == DISPOSITION_NEEDS_HUMAN:
1952
+ _reactivate_currency_item(currency, item)
1953
+ item.pop("requires_decision", None)
1954
+ item.pop("pending_decision", None)
1955
+ item.pop("answered_decision", None)
1956
+ state.pop("needs_human_residuals", None)
1957
+ state.pop("needs_human_check_observations", None)
1958
+
1959
+
1960
+ def _human_decision_identity(decision):
1961
+ if not isinstance(decision, dict):
1962
+ return None
1963
+ residual, sources = decision.get("residual"), decision.get("sources")
1964
+ if not isinstance(residual, dict) or not isinstance(sources, list):
1965
+ return None
1966
+ return _decision_id(residual, sources)
1967
+
1968
+
1969
+ def _replace_human_decisions(
1970
+ state, kept, previous=None, threads=None, feedback=None, checks=None):
1971
+ """Replace canonical decisions and reactivate exact surviving source evidence."""
1972
+ previous = previous if isinstance(previous, list) else state.get("human_decisions")
1973
+ previous = previous if isinstance(previous, list) else []
1974
+ kept_identities = {
1975
+ identity for decision in kept
1976
+ if (identity := _human_decision_identity(decision)) is not None
1977
+ }
1978
+ removed = [
1979
+ decision for decision in previous
1980
+ if (_human_decision_identity(decision) not in kept_identities)
1981
+ ]
1982
+ _install_human_decisions(state, kept)
1983
+
1984
+ threads = threads if isinstance(threads, dict) else state.get("threads", {})
1985
+ feedback = feedback if isinstance(feedback, dict) else state.get("feedback", {})
1986
+ checks = checks if isinstance(checks, dict) else state.get("checks", {})
1987
+ for decision in removed:
1988
+ if not isinstance(decision, dict):
1989
+ continue
1990
+ for source in decision.get("sources") or []:
1991
+ if not isinstance(source, dict):
1992
+ continue
1993
+ kind, source_id = source.get("kind"), source.get("id")
1994
+ current = _current_source_observation(
1995
+ state, kind, source_id, threads, feedback, checks)
1996
+ if current != source.get("observation") or kind != "currency":
1997
+ continue
1998
+ # Review and CI retain ordinary open state; currency has an explicit claim to clear.
1999
+ currency = _load_branch_currency_state(state)
2000
+ item = currency.get("items", {}).get(source_id)
2001
+ if isinstance(item, dict):
2002
+ _reactivate_currency_item(currency, item)
2003
+
2004
+
2005
+ def _reconcile_human_decisions(state, threads, feedback, checks, now):
2006
+ """Let canonical decisions alone suppress exact evidence; activity is never an answer."""
2007
+ current = state.get("human_decisions")
2008
+ if not isinstance(current, list):
2009
+ current = _legacy_decision_records(state, threads, feedback, checks, now)
2010
+ elif not current and state.get("needs_human_residuals"):
2011
+ current = _legacy_decision_records(state, threads, feedback, checks, now)
2012
+ _install_human_decisions(state, current)
2013
+ answered = state.get("answered_human_decisions")
2014
+ if not isinstance(answered, list):
2015
+ answered = _legacy_answered_decision_records(
2016
+ state, threads, feedback, checks)
2017
+ elif not answered:
2018
+ answered = _legacy_answered_decision_records(
2019
+ state, threads, feedback, checks)
2020
+ _normalize_legacy_source_state(state, threads, feedback)
2021
+
2022
+ kept, seen = [], set()
2023
+ for decision in current:
2024
+ if not isinstance(decision, dict) or _needs_human_residual_error(decision.get("residual")):
2025
+ continue
2026
+ sources = decision.get("sources")
2027
+ if not isinstance(sources, list) or len(sources) != len(decision["residual"]["sources"]):
2028
+ continue
2029
+ expected = {(source.get("kind"), source.get("id")) for source in sources}
2030
+ if expected != {_source_identity(source) for source in decision["residual"]["sources"]}:
2031
+ continue
2032
+ if not all(
2033
+ source.get("observation") is not None
2034
+ and _current_source_observation(
2035
+ state, source.get("kind"), source.get("id"), threads, feedback, checks)
2036
+ == source.get("observation")
2037
+ for source in sources):
2038
+ continue
2039
+ normalized = _decision_record(decision["residual"], sources, decision.get("created_at"))
2040
+ if normalized["id"] in seen:
2041
+ continue
2042
+ seen.add(normalized["id"])
2043
+ kept.append(normalized)
2044
+
2045
+ _replace_human_decisions(
2046
+ state, kept, current, threads=threads, feedback=feedback, checks=checks)
2047
+ kept_answers = []
2048
+ for decision in answered:
2049
+ if not isinstance(decision, dict) or not isinstance(decision.get("answer"), str):
2050
+ continue
2051
+ sources = decision.get("sources") or []
2052
+ matching_actionable = any(
2053
+ _current_source_observation(
2054
+ state, source.get("kind"), source.get("id"), threads, feedback, checks)
2055
+ == source.get("observation")
2056
+ and _source_is_actionable(
2057
+ state, source.get("kind"), source.get("id"), threads, feedback, checks)
2058
+ for source in sources if isinstance(source, dict))
2059
+ if matching_actionable:
2060
+ kept_answers.append(decision)
2061
+
2062
+ state["answered_human_decisions"] = kept_answers
2063
+ covered = {
2064
+ (source["kind"], source["id"])
2065
+ for decision in kept for source in decision["sources"]
2066
+ }
2067
+ public = []
2068
+ for decision in kept:
2069
+ public.append(json.loads(json.dumps(decision["residual"])))
2070
+ return public, covered
2071
+
2072
+
2073
+ def diff(state, cur, now=None, advance_trajectory=True):
2074
+ """Pure: given prior state + current snapshot, compute the actionable set
2075
+ and the persisted observed state. `now` is injectable for tests."""
2076
+ now = now or _now()
2077
+ # A transient null/empty head (a gh hiccup) falls back to the last known head,
2078
+ # so a momentary null does not look like a new head and wipe ci_dispatched.
2079
+ head = cur["head_sha"] or state.get("head_sha")
2080
+ head_changed = state.get("head_sha") is not None and head != state["head_sha"]
2081
+ _apply_stale_merge_computation(state, cur, head, now)
2082
+
2083
+ prior_threads = state.get("threads", {})
2084
+ prior_feedback = state.get("feedback", {})
2085
+ prior_feedback_activity = {
2086
+ fid: item.get("edit_id") for fid, item in prior_feedback.items()
2087
+ }
2088
+ prior_review_decision = state.get("review_decision")
2089
+ prior_change_sig = _change_sig(state)
2090
+ if head_changed:
2091
+ # SHA-scoped state is meaningless on a new head.
2092
+ state["ci_dispatched"] = {}
2093
+
2094
+ # --- CI: a failing check on the current head is actionable until the agent
2095
+ # marks it dispatched (recorded in ci_dispatched[head]) or the head moves.
2096
+ # `checks_terminal` = every check has finished (none IN_PROGRESS/QUEUED).
2097
+ # Duplicate check keys (same workflow/name) are disambiguated with a #n suffix
2098
+ # so one never shadows another and silently drops a failing check. ---
2099
+ new_checks = {}
2100
+ failing_check_items = {}
2101
+ has_failing = False
2102
+ checks_terminal = True
2103
+ seen_keys = {}
2104
+ for c in cur["checks"]:
2105
+ key = c["key"]
2106
+ if key in seen_keys:
2107
+ seen_keys[key] += 1
2108
+ key = f"{key}#{seen_keys[key]}"
2109
+ else:
2110
+ seen_keys[key] = 0
2111
+ new_checks[key] = {
2112
+ "name": c["name"], "status": c["status"],
2113
+ "conclusion": c["conclusion"], "head_sha": head,
2114
+ "details_url": c.get("details_url"),
2115
+ "created_at": c.get("created_at"),
2116
+ "started_at": c.get("started_at"),
2117
+ "completed_at": c.get("completed_at"),
2118
+ }
2119
+ if c["status"] != "COMPLETED":
2120
+ checks_terminal = False
2121
+ if c["conclusion"] in FAILING:
2122
+ has_failing = True
2123
+ failing_check_items[key] = {
2124
+ "key": key, "name": c["name"], "conclusion": c["conclusion"],
2125
+ "details_url": c["details_url"],
2126
+ }
2127
+
2128
+ # Review streams keep only ordinary open/dispatched state. Human decisions are a separate
2129
+ # exact-observation coverage layer reconciled below, so a remote edit invalidates a question
2130
+ # but is never mistaken for the user's answer.
2131
+ new_threads, _, _ = _apply_dispositions(
2132
+ cur["threads"], "thread_id", prior_threads,
2133
+ identity_fn=lambda t: [t.get("last_comment_id"), t.get("last_comment_at")])
2134
+ new_feedback, _, _ = _apply_dispositions(
2135
+ cur.get("feedback") or [], "id", prior_feedback,
2136
+ identity_fn=lambda c: [c.get("edit_id")], reactivate_dispositions=())
2137
+ # Reconcile currency before exact decision observations are compared.
2138
+ branch_currency = _apply_branch_currency(state, cur, head, head_changed, now)
2139
+ needs_human_residuals, decision_coverage = _reconcile_human_decisions(
2140
+ state, new_threads, new_feedback, new_checks, now)
2141
+ dispatched = set(state.get("ci_dispatched", {}).get(head, []))
2142
+ # Every attention stream is derived from ordinary state minus current decision coverage.
2143
+ actionable_threads = [
2144
+ record for source_id, record in new_threads.items()
2145
+ if record.get("disposition") == DISPOSITION_OPEN
2146
+ and ("thread", source_id) not in decision_coverage]
2147
+ actionable_feedback = [
2148
+ record for source_id, record in new_feedback.items()
2149
+ if record.get("disposition") == DISPOSITION_OPEN
2150
+ and (record.get("kind", "comment"), source_id) not in decision_coverage]
2151
+ actionable_ci = [
2152
+ item for key, item in failing_check_items.items()
2153
+ if key not in dispatched and ("check", key) not in decision_coverage]
2154
+ current_thread_activity = {
2155
+ item.get("thread_id"): _thread_identity(item)
2156
+ for item in cur["threads"] if item.get("thread_id")
2157
+ }
2158
+ prior_thread_activity = {
2159
+ tid: _prior_thread_activity_identity(item) for tid, item in prior_threads.items()
2160
+ if not (item.get("disposition") == DISPOSITION_DISPATCHED
2161
+ and tid not in current_thread_activity)
2162
+ }
2163
+ current_feedback_activity = {
2164
+ item.get("id"): item.get("edit_id")
2165
+ for item in (cur.get("feedback") or []) if item.get("id")
2166
+ }
2167
+ external_review_moved = (
2168
+ head_changed
2169
+ or current_thread_activity != prior_thread_activity
2170
+ or current_feedback_activity != prior_feedback_activity
2171
+ or cur.get("review_decision") != prior_review_decision
2172
+ )
2173
+ open_needs_human = len(needs_human_residuals)
2174
+ # Compatibility view for consumers that display source identities. The canonical set above,
2175
+ # not this flattened list, owns persistence and stop behavior.
2176
+ needs_human_ids = sorted({
2177
+ source["id"] for residual in needs_human_residuals for source in residual["sources"]})
2178
+
2179
+ state["head_sha"] = head or state.get("head_sha")
2180
+ state["checks"] = new_checks
2181
+ state["threads"] = new_threads
2182
+ state["feedback"] = new_feedback
2183
+ state["review_decision"] = cur["review_decision"]
2184
+ state["mergeable"] = cur["mergeable"]
2185
+ state["merge_state_status"] = cur["merge_state_status"]
2186
+ observed_awaiting_approval = cur.get("awaiting_approval")
2187
+ approval_probe_succeeded = observed_awaiting_approval is not None
2188
+ awaiting_approval = (
2189
+ observed_awaiting_approval
2190
+ if approval_probe_succeeded
2191
+ else state.get("awaiting_approval", 0)
2192
+ )
2193
+ state["awaiting_approval"] = awaiting_approval
2194
+ if awaiting_approval > 0:
2195
+ gate_is_new_for_head = (
2196
+ state.get("blocked_external_head_sha") != head
2197
+ or not state.get("blocked_external_review_last_activity_at")
2198
+ )
2199
+ if gate_is_new_for_head:
2200
+ state["blocked_external_head_sha"] = head
2201
+ state["blocked_external_first_seen_at"] = _iso(now)
2202
+ state["blocked_external_review_last_activity_at"] = _iso(now)
2203
+ elif external_review_moved:
2204
+ state["blocked_external_review_last_activity_at"] = _iso(now)
2205
+ elif approval_probe_succeeded:
2206
+ state["blocked_external_head_sha"] = None
2207
+ state["blocked_external_first_seen_at"] = None
2208
+ state["blocked_external_review_last_activity_at"] = None
2209
+ state["pr_chain"] = cur.get("pr_chain") or _default_pr_chain()
2210
+ unrequested_base_merge = _detect_unrequested_base_merge(state, cur, head, head_changed, now)
2211
+ unrequested_base_merge_pending = state.get("unrequested_base_merge_pending") == head
2212
+ if unrequested_base_merge_pending and isinstance(cur.get("base"), dict) \
2213
+ and _base_ref_blocker(cur["base"]) is None:
2214
+ # An unclassified head is a pending merge computation: whatever else the base identity says
2215
+ # (current or a bounded stale-computation degrade), it is not certain, not ready, not landable.
2216
+ cur["base"]["identity"] = BASE_REF_MERGEABILITY_PENDING
2217
+ state["base"] = cur.get("base")
2218
+ if branch_currency and branch_currency.get("disposition") == DISPOSITION_OPEN:
2219
+ _prepare_open_currency_item(
2220
+ state, _load_branch_currency_state(state), branch_currency["key"],
2221
+ branch_currency, now)
2222
+ if branch_currency and ("currency", branch_currency.get("key")) in decision_coverage:
2223
+ branch_currency["attention"] = None
2224
+
2225
+ actionable = {"ci": actionable_ci, "threads": actionable_threads, "comments": actionable_feedback}
2226
+ # Reset on any change to the candidate id-set so a burst arriving over several polls settles
2227
+ # into one wake; clear when it empties so the next candidate starts a fresh window.
2228
+ feedback_ids = sorted(str(f.get("id")) for f in actionable_feedback)
2229
+ if not feedback_ids:
2230
+ state["feedback_candidate_changed_at"] = None
2231
+ elif feedback_ids != state.get("feedback_candidate_ids"):
2232
+ state["feedback_candidate_changed_at"] = _iso(now)
2233
+ state["feedback_candidate_ids"] = feedback_ids
2234
+ if advance_trajectory:
2235
+ state["tick"] = state.get("tick", 0) + 1
2236
+ # Record CI recurrence transitions on EVERY observation (polls too) so a fail->clear->fail seen
2237
+ # only between agent ticks is not lost. head_sha for the check-level last_head is the observed
2238
+ # head; the trajectory-level last_head (for heads_since_progress) is agent-tick-only, inside
2239
+ # _update_trajectory.
2240
+ _record_check_history(state, head, new_checks)
2241
+ if advance_trajectory:
2242
+ trajectory = _update_trajectory(
2243
+ state, head, new_checks, new_threads, new_feedback, actionable, decision_coverage)
2244
+ else:
2245
+ # A watch poll detects change and advances the settle clock, but must NOT roll the rest of the
2246
+ # trajectory (tick counter, seen_threads, unresolved_series, heads_since_progress). Advancing
2247
+ # it would consume new_threads_this_tick — the waking poll marks the just-arrived thread
2248
+ # "seen", so the agent's real tick reads 0 new arrivals and the review-bot-treadmill
2249
+ # non-convergence trigger never fires. Only the agent's tick (advance_trajectory=True) rolls it.
2250
+ trajectory = {}
2251
+
2252
+ # --- Settle window: any observable movement resets the quiet clock. ---
2253
+ changed_this_tick = head_changed or _change_sig(state) != prior_change_sig or state.get("last_change_at") is None
2254
+ if changed_this_tick:
2255
+ state["last_change_at"] = _iso(now)
2256
+ quiet_seconds = _elapsed(state.get("last_change_at"), now)
2257
+
2258
+ # Workflow runs awaiting maintainer approval (fork-PR gate) create no check-run, so they are
2259
+ # invisible to the rollup above — surface them, and never call CI "ok" while the real CI is
2260
+ # gated. blocked_external = the loop cannot progress (no failing check to fix, but CI can't run)
2261
+ # and no one in this loop can unblock it — it is up to a maintainer of the base repo.
2262
+ awaiting_approval = state["awaiting_approval"]
2263
+ blocked_external_review_quiet_seconds = (
2264
+ _elapsed(state.get("blocked_external_review_last_activity_at"), now)
2265
+ if awaiting_approval > 0 else 0
2266
+ )
2267
+ # "OK" requires every check terminal, none failing, AND none gated on approval. A still-
2268
+ # IN_PROGRESS or awaiting-approval check is neither ok nor failing — do not exit green.
2269
+ all_checks_ok = checks_terminal and not has_failing and bool(cur["checks"]) and awaiting_approval == 0
2270
+ blocked_external = (awaiting_approval > 0 and not has_failing and checks_terminal
2271
+ and not actionable_threads and not actionable_feedback)
2272
+ invocation_elapsed = _active_elapsed(state, now)
2273
+
2274
+ return {
2275
+ "pr_state": cur["pr_state"],
2276
+ "pr_is_draft": cur.get("pr_is_draft"),
2277
+ "mergeable": cur["mergeable"],
2278
+ "merge_state_status": cur["merge_state_status"],
2279
+ "review_decision": cur["review_decision"],
2280
+ "head_sha": head,
2281
+ "head_changed": head_changed,
2282
+ "base": state.get("base"),
2283
+ "mergeability_certain": _mergeability_certain(
2284
+ cur.get("mergeable"), cur.get("merge_state_status"), cur.get("base")),
2285
+ "base_ref_blocker": _base_ref_blocker(cur.get("base")),
2286
+ "host_branch_update_capability": cur.get("host_branch_update_capability", "unknown"),
2287
+ "branch_currency": branch_currency,
2288
+ "unrequested_base_merge": unrequested_base_merge,
2289
+ "unrequested_base_merge_pending": unrequested_base_merge_pending,
2290
+ "branch_currency_blocker": ({
2291
+ "key": branch_currency.get("key"),
2292
+ "disposition": branch_currency.get("disposition"),
2293
+ "recovery_state": branch_currency.get("recovery_state"),
2294
+ } if branch_currency else None),
2295
+ "url": cur["url"],
2296
+ "has_failing_checks": has_failing,
2297
+ "checks_terminal": checks_terminal,
2298
+ "checks_present": bool(cur["checks"]),
2299
+ "all_checks_ok": all_checks_ok,
2300
+ "checks_awaiting_approval": awaiting_approval,
2301
+ "blocked_external": blocked_external,
2302
+ "blocked_external_first_seen_at": state.get("blocked_external_first_seen_at"),
2303
+ "feedback_candidate_changed_at": state.get("feedback_candidate_changed_at"),
2304
+ "blocked_external_review_last_activity_at": state.get(
2305
+ "blocked_external_review_last_activity_at"),
2306
+ "blocked_external_review_quiet_seconds": blocked_external_review_quiet_seconds,
2307
+ "blocked_external_review_moved_this_tick": (
2308
+ awaiting_approval > 0 and external_review_moved),
2309
+ "pr_chain": state["pr_chain"],
2310
+ "stack_blocker": _stack_blocker(state["pr_chain"], cur),
2311
+ "open_needs_human": open_needs_human,
2312
+ "needs_human_ids": needs_human_ids,
2313
+ "needs_human_residuals": needs_human_residuals,
2314
+ "human_decisions": [
2315
+ {"decision_id": decision.get("id"), "residual": decision.get("residual")}
2316
+ for decision in state.get("human_decisions", []) if isinstance(decision, dict)
2317
+ ],
2318
+ "answered_human_decisions": state.get("answered_human_decisions", []),
2319
+ "actionable": {"threads": actionable_threads, "ci": actionable_ci, "comments": actionable_feedback},
2320
+ "counts": {"threads": len(actionable_threads), "ci": len(actionable_ci),
2321
+ "comments": len(actionable_feedback), "needs_human": open_needs_human},
2322
+ "changed_this_tick": changed_this_tick,
2323
+ "quiet_seconds": quiet_seconds,
2324
+ "invocation_id": state.get("invocation_id"),
2325
+ "invocation_started_at": state.get("started_at"),
2326
+ "invocation_elapsed_seconds": invocation_elapsed,
2327
+ "invocation_budget_seconds": state.get("invocation_budget_seconds"),
2328
+ "invocation_remaining_seconds": max(
2329
+ 0,
2330
+ (state.get("invocation_budget_seconds") or 0) - invocation_elapsed,
2331
+ ),
2332
+ "invocation_wall_elapsed_seconds": _elapsed(state.get("started_at"), now),
2333
+ "invocation_dead_time_seconds": int(state.get("dead_time_seconds") or 0),
2334
+ "invocation_backstop_seconds": state.get("invocation_backstop_seconds"),
2335
+ "persisted_state_created_at": state.get("state_created_at"),
2336
+ "persisted_state_age_seconds": _elapsed(state.get("state_created_at"), now),
2337
+ # Compatibility aliases for callers migrating from the original single-clock contract.
2338
+ "session_started_at": state.get("started_at"),
2339
+ "session_seconds": invocation_elapsed,
2340
+ "watch_generation": state.get("watch_generation"),
2341
+ "tick": state["tick"],
2342
+ "trajectory": trajectory,
2343
+ }, state
2344
+
2345
+
2346
+ def _settle_base(base):
2347
+ """Base fields whose movement should reset the settle clock.
2348
+
2349
+ `stale-computation` is a presentation of a still-stable `race` candidate
2350
+ (same head + live base) plus a report flag. Hashing those presentation
2351
+ fields would reset last_change_at and add a synthetic settle delay.
2352
+ Genuine identity values and freshness stay in the signature.
2353
+ """
2354
+ if not isinstance(base, dict):
2355
+ return base
2356
+ payload = {k: v for k, v in base.items() if k != "merge_computation_stale"}
2357
+ if payload.get("identity") == BASE_REF_STALE_COMPUTATION:
2358
+ payload["identity"] = BASE_REF_RACE
2359
+ return payload
2360
+
2361
+
2362
+ def _change_sig(state):
2363
+ """Everything whose movement should reset the settle clock."""
2364
+ checks = {k: (v.get("status"), v.get("conclusion")) for k, v in state.get("checks", {}).items()}
2365
+ threads = {tid: _thread_identity(v) for tid, v in state.get("threads", {}).items()}
2366
+ # edit_id is part of the signal so a body edit of a silenced item still resets the settle
2367
+ # clock (review activity happened) even though it no longer reopens the item (#1309).
2368
+ feedback = {fid: (v.get("disposition"), v.get("edit_id"))
2369
+ for fid, v in state.get("feedback", {}).items()}
2370
+ human_decisions = sorted(
2371
+ item.get("id") for item in state.get("human_decisions", [])
2372
+ if isinstance(item, dict))
2373
+ currency = _load_branch_currency_state(state)
2374
+ current_currency = currency.get("items", {}).get(currency.get("current_key")) or {}
2375
+ return (checks, threads, feedback, human_decisions,
2376
+ state.get("review_decision"), state.get("mergeable"),
2377
+ state.get("merge_state_status"),
2378
+ json.dumps(state.get("pr_chain") or _default_pr_chain(), sort_keys=True),
2379
+ json.dumps(_settle_base(state.get("base")), sort_keys=True),
2380
+ (current_currency.get("host_branch_update_capability")
2381
+ if current_currency.get("status") == "BEHIND" else None),
2382
+ currency.get("current_key"), current_currency.get("disposition"),
2383
+ current_currency.get("attention"), current_currency.get("recovery_state"),
2384
+ current_currency.get("retry_count"), current_currency.get("mutation_consumed"),
2385
+ # awaiting-approval clearing is movement: a fork gate lifting must reset the settle clock
2386
+ # so merge-ready waits for the now-imminent check-runs instead of firing on an empty rollup.
2387
+ bool(state.get("awaiting_approval")))
2388
+
2389
+
2390
+ def _stack_blocker(chain, cur=None):
2391
+ """Return the manager-currency residual that blocks target readiness, if any.
2392
+ Manager freshness (`needsRebase`) fires on plain trunk drift, which GitHub already prices into
2393
+ the target's own mergeability against its parent base; so a certain MERGEABLE/CLEAN read defers
2394
+ to GitHub and only a stale-or-unknown target that GitHub does not clear blocks."""
2395
+ status = (chain or {}).get("manager_status")
2396
+ if status == MANAGER_PROBE_ERROR:
2397
+ return "manager-probe-error"
2398
+ if (chain or {}).get("relationship_status") == RELATIONSHIP_PROBE_ERROR:
2399
+ return "relationship-probe-error"
2400
+ if status == MANAGER_CONFIRMED:
2401
+ freshness = chain.get("target_needs_rebase")
2402
+ if freshness is False:
2403
+ return None
2404
+ cur = cur or {}
2405
+ if (cur.get("mergeable") == "MERGEABLE" and cur.get("merge_state_status") == "CLEAN"
2406
+ and _mergeability_certain(cur.get("mergeable"), cur.get("merge_state_status"),
2407
+ cur.get("base"))):
2408
+ return None
2409
+ if freshness is True:
2410
+ return "target-needs-rebase"
2411
+ return "managed-freshness-unknown"
2412
+ return None
2413
+
2414
+
2415
+ def _parse_iso8601(value):
2416
+ if isinstance(value, str) and value.endswith("Z"):
2417
+ value = value[:-1] + "+00:00"
2418
+ return datetime.fromisoformat(value)
2419
+
2420
+
2421
+ def _elapsed(iso_str, now):
2422
+ try:
2423
+ return int((now - _parse_iso8601(iso_str)).total_seconds())
2424
+ except (ValueError, TypeError):
2425
+ return 0
2426
+
2427
+
2428
+ def _advance_activity(state, now, threshold=DEAD_TIME_THRESHOLD_SECONDS, accumulate=True):
2429
+ """Advance the activity heartbeat, and (for the in-session watch) accumulate suspended time.
2430
+
2431
+ Every watch poll and every agent snapshot/mark marks activity. A gap wider than `threshold`
2432
+ means the whole process was not running — a suspended machine — so, when accumulating, the
2433
+ excess beyond the threshold is charged to dead time and later excluded from active elapsed.
2434
+ Agent snapshots/marks bump the heartbeat with `accumulate=False`, so active agent work keeps the
2435
+ heartbeat fresh and is not refunded as long as the agent touches state (snapshots or marks) within
2436
+ the threshold; a single silent tick longer than the threshold is the coarse-discriminator limit
2437
+ the plan defers, bounded by the wall-clock backstop. Dead time only ever grows in the watch loop.
2438
+ Clock-backward safe: `_elapsed` floors negative deltas at 0 and the accumulator never decreases.
2439
+ """
2440
+ last = state.get("last_activity_at") or state.get("started_at")
2441
+ if accumulate:
2442
+ gap = _elapsed(last, now)
2443
+ if gap > threshold:
2444
+ state["dead_time_seconds"] = (state.get("dead_time_seconds") or 0) + (gap - threshold)
2445
+ state["last_activity_at"] = _iso(now)
2446
+
2447
+
2448
+ def _active_elapsed(state, now):
2449
+ """Wall-clock elapsed since the invocation anchor, minus accumulated suspended (dead) time."""
2450
+ wall = _elapsed(state.get("started_at"), now)
2451
+ return max(0, wall - int(state.get("dead_time_seconds") or 0))
2452
+
2453
+
2454
+ def _session_started_at(value):
2455
+ """Parse one invocation-wide, timezone-aware budget anchor for reuse across state dirs."""
2456
+ try:
2457
+ parsed = _parse_iso8601(value)
2458
+ except (ValueError, TypeError):
2459
+ raise argparse.ArgumentTypeError("must be an ISO-8601 timestamp")
2460
+ if parsed.tzinfo is None:
2461
+ raise argparse.ArgumentTypeError("must include a timezone")
2462
+ return _iso(parsed.astimezone(timezone.utc))
2463
+
2464
+
2465
+ @contextmanager
2466
+ def locked_state(state_dir, pr, repo, now):
2467
+ state_path = os.path.join(state_dir, "state.json")
2468
+ with _state_lock(state_dir):
2469
+ if os.path.exists(state_path):
2470
+ with open(state_path) as f:
2471
+ state = json.load(f)
2472
+ else:
2473
+ state = _empty_state(pr, repo, None, now)
2474
+ # Older state used `started_at` for both durable-state age and the active budget. Preserve
2475
+ # that original value as the state birth time before a new invocation replaces the clock.
2476
+ state.setdefault("state_created_at", state.get("started_at") or _iso(now))
2477
+ # Active-time accounting fields migrate onto legacy state on first observation. Seed the
2478
+ # heartbeat to *now*, not the old anchor: there is no recorded activity for the pre-migration
2479
+ # period, so charging that whole span as one supra-threshold gap would refund the entire
2480
+ # historical invocation (a 9h-old 8h run would read as ~15 min active and never max-runtime).
2481
+ # Seeding to load time makes the first poll see a ~0 gap and keeps elapsed on real wall-clock.
2482
+ state.setdefault("last_activity_at", _iso(now))
2483
+ state.setdefault("dead_time_seconds", 0.0)
2484
+ state.setdefault("invocation_backstop_seconds", DEFAULT_INVOCATION_BACKSTOP_SECONDS)
2485
+ box = {"state": state}
2486
+ yield box
2487
+ tmp = tempfile.NamedTemporaryFile("w", dir=state_dir, delete=False)
2488
+ json.dump(box["state"], tmp, indent=2)
2489
+ tmp.flush()
2490
+ os.fsync(tmp.fileno())
2491
+ tmp.close()
2492
+ _replace_atomic(tmp.name, state_path)
2493
+
2494
+
2495
+ def _fetch_snapshot(args):
2496
+ """Fetch current PR state without mutating the persisted babysit state."""
2497
+ if args.fetch_file:
2498
+ with open(args.fetch_file) as f:
2499
+ return json.load(f)
2500
+ return fetch(args.pr, args.repo)
2501
+
2502
+
2503
+ def _apply_invocation(box, args, now):
2504
+ """Start explicitly, or reuse the one fixed clock named by an explicit invocation token."""
2505
+ state = box["state"]
2506
+ start_requested = bool(getattr(args, "start_invocation", False)
2507
+ or getattr(args, "reset_session", False))
2508
+ continue_requested = bool(getattr(args, "continue_invocation", False))
2509
+ invocation_id = getattr(args, "invocation_id", None)
2510
+ requested_started_at = getattr(args, "session_started_at", None)
2511
+ requested_budget = getattr(args, "invocation_budget_seconds", None)
2512
+
2513
+ if start_requested:
2514
+ invocation_id = uuid.uuid4().hex
2515
+ requested_started_at = _iso(now)
2516
+ requested_budget = requested_budget or DEFAULT_INVOCATION_BUDGET_SECONDS
2517
+ state["invocation_id"] = invocation_id
2518
+ state["started_at"] = requested_started_at
2519
+ state["invocation_budget_seconds"] = requested_budget
2520
+ # A fresh invocation resets the active-time clock: heartbeat at now, no dead time yet.
2521
+ state["last_activity_at"] = _iso(now)
2522
+ state["dead_time_seconds"] = 0.0
2523
+ state.setdefault("invocation_backstop_seconds", DEFAULT_INVOCATION_BACKSTOP_SECONDS)
2524
+ args.invocation_id = invocation_id
2525
+ args.session_started_at = requested_started_at
2526
+ args.invocation_budget_seconds = requested_budget
2527
+ args._started_new_invocation = True
2528
+ return
2529
+
2530
+ if not invocation_id:
2531
+ raise SystemExit("pass --start-invocation on the first snapshot or --invocation-id to resume it")
2532
+
2533
+ if continue_requested:
2534
+ if not requested_started_at or not requested_budget:
2535
+ raise SystemExit("--continue-invocation requires its fixed start and budget")
2536
+ if state.get("invocation_id") == invocation_id:
2537
+ try:
2538
+ same_anchor = (_parse_iso8601(state.get("started_at"))
2539
+ == _parse_iso8601(requested_started_at))
2540
+ except (ValueError, TypeError):
2541
+ same_anchor = False
2542
+ if not same_anchor or state.get("invocation_budget_seconds") != requested_budget:
2543
+ raise SystemExit("continuation cannot renew or extend an existing invocation")
2544
+ # Honor a carry arg on a same-invocation re-continue too, as a monotonic floor: raise the
2545
+ # dead-time to the carried value without clobbering time this dir already accumulated
2546
+ # (dead time only ever grows). This makes the carry order-independent — it lands whether
2547
+ # it arrives on the first adopt or a later re-continue.
2548
+ continue_dead_time = getattr(args, "continue_dead_time_seconds", None)
2549
+ if continue_dead_time is not None:
2550
+ state["dead_time_seconds"] = max(
2551
+ state.get("dead_time_seconds") or 0, float(continue_dead_time))
2552
+ return
2553
+ state["invocation_id"] = invocation_id
2554
+ state["started_at"] = requested_started_at
2555
+ state["invocation_budget_seconds"] = requested_budget
2556
+ # Adopting an invocation into this state dir establishes a fresh active-time clock. Seed the
2557
+ # heartbeat to now (not the shared anchor, which may be hours old) so the first poll sees a
2558
+ # ~0 gap and does not refund the span between the anchor and adoption as dead time.
2559
+ state["last_activity_at"] = _iso(now)
2560
+ # Dead time is per-state-dir, but a managed-stack continuation shares one active-time budget
2561
+ # across layers, so it explicitly carries the prior layer's accumulated dead time here. Absent
2562
+ # that arg (an ordinary adopt of an unrelated dir), reset to 0 rather than inherit stale state.
2563
+ continue_dead_time = getattr(args, "continue_dead_time_seconds", None)
2564
+ state["dead_time_seconds"] = float(continue_dead_time) if continue_dead_time is not None else 0.0
2565
+ state.setdefault("invocation_backstop_seconds", DEFAULT_INVOCATION_BACKSTOP_SECONDS)
2566
+ return
2567
+
2568
+ persisted_id = state.get("invocation_id")
2569
+ persisted_started_at = state.get("started_at")
2570
+ persisted_budget = state.get("invocation_budget_seconds")
2571
+ if persisted_id != invocation_id:
2572
+ raise SystemExit("invocation token does not match persisted state; start or continue explicitly")
2573
+ try:
2574
+ anchors_match = (_parse_iso8601(persisted_started_at)
2575
+ == _parse_iso8601(requested_started_at))
2576
+ except (ValueError, TypeError):
2577
+ anchors_match = False
2578
+ if not anchors_match:
2579
+ raise SystemExit("invocation token does not match the persisted budget anchor")
2580
+ if persisted_budget != requested_budget:
2581
+ raise SystemExit("invocation token does not match the persisted fixed budget")
2582
+
2583
+
2584
+ def _apply_snapshot(box, args, cur, now, advance_trajectory):
2585
+ """Apply one fetched snapshot to a caller-owned locked state box."""
2586
+ if box["state"].get("pr", {}).get("url") is None and cur.get("url"):
2587
+ box["state"]["pr"]["url"] = cur["url"]
2588
+ _apply_invocation(box, args, now)
2589
+ return diff(box["state"], cur, now, advance_trajectory=advance_trajectory)
2590
+
2591
+
2592
+ def _run_snapshot(args, now, advance_trajectory=True, watch_generation=None):
2593
+ """One fetch -> diff -> persist. Returns the actionable/state dict."""
2594
+ cur = _fetch_snapshot(args)
2595
+ # Re-capture the clock *after* the (blocking) fetch so activity accounting reflects post-fetch
2596
+ # time: a suspend that lands during the fetch is then credited as dead time by this poll's
2597
+ # _advance_activity, instead of leaving the next top-of-loop budget check to fire max-runtime on
2598
+ # a gap it hasn't yet excluded. It also keeps a new invocation's slow first fetch off its budget.
2599
+ now = _now()
2600
+ with locked_state(args.state_dir, args.pr, args.repo, now) as box:
2601
+ if watch_generation is not None and box["state"].get("watch_generation") != watch_generation:
2602
+ raise _WatchSuperseded()
2603
+ if (watch_generation is not None
2604
+ and box["state"].get("invocation_id") != getattr(args, "invocation_id", None)):
2605
+ raise _InvocationSuperseded(box["state"].get("invocation_id"))
2606
+ # Only the self-sustaining in-session watch (watch_generation set) accumulates dead time;
2607
+ # an agent-driven snapshot bumps the heartbeat without accumulating, so checkpoint/durable
2608
+ # runs — which have no continuous poll cadence — keep pure wall-clock accounting.
2609
+ if box["state"].get("started_at"):
2610
+ _advance_activity(box["state"], now, accumulate=watch_generation is not None)
2611
+ actionable, box["state"] = _apply_snapshot(box, args, cur, now, advance_trajectory)
2612
+ return actionable
2613
+
2614
+
2615
+ def cmd_snapshot(args):
2616
+ result = _run_snapshot(args, _now())
2617
+ if (getattr(args, "_started_new_invocation", False)
2618
+ and _elapsed(result.get("invocation_started_at"), _now()) > 60):
2619
+ raise SystemExit("new invocation unexpectedly inherited more than 60 seconds of elapsed time")
2620
+ print(json.dumps(result, separators=(",", ":")))
2621
+
2622
+
2623
+ def _wake_reason(a, settle_seconds, feedback_coalesce_seconds=FEEDBACK_COALESCE_SECONDS):
2624
+ """Why the in-session agent should wake and run a tick, or None to keep watching.
2625
+ Ordered by precedence — a terminal/blocked/needs-human state outranks a merge-ready read."""
2626
+ if a.get("pr_state") in ("MERGED", "CLOSED"):
2627
+ return "terminal"
2628
+ c = a.get("counts") or {}
2629
+ if c.get("threads", 0) or c.get("ci", 0):
2630
+ return "actionable"
2631
+ if c.get("comments", 0):
2632
+ # Coalesced per FEEDBACK_COALESCE_SECONDS above; falling through costs nothing, since the
2633
+ # set stays actionable and whichever tick runs still sees every candidate. `_elapsed` reads
2634
+ # a missing or unparseable clock as 0, so unknown must wake rather than hold forever.
2635
+ changed_at = a.get("feedback_candidate_changed_at")
2636
+ if changed_at is None or _elapsed(changed_at, _now()) >= feedback_coalesce_seconds:
2637
+ return "feedback-candidate"
2638
+ if a.get("stack_blocker"):
2639
+ return "stack-blocked"
2640
+ base_ref_blocker = (a.get("base_ref_blocker")
2641
+ if "base_ref_blocker" in a else _base_ref_blocker(a.get("base")))
2642
+ if base_ref_blocker:
2643
+ return "base-ref-blocked"
2644
+ if a.get("unrequested_base_merge"):
2645
+ return "unrequested-base-merge"
2646
+ branch_currency = a.get("branch_currency") or {}
2647
+ if a.get("has_failing_checks") and a.get("checks_terminal"):
2648
+ # a dispatched check left terminally red (counts.ci is 0 — nothing new to dispatch) is a
2649
+ # blocker to hand back, not a reason to idle to max-runtime.
2650
+ return "blocked-failing"
2651
+ # Branch maintenance is a third attention stream. It runs only after new review/CI work and
2652
+ # standing red blockers are clear, but a passive in-progress check or review signal does not
2653
+ # delay a BEHIND update that will invalidate that work anyway. Claimed observations wake only
2654
+ # for reconciliation; confirmed and parked residuals remain quiet.
2655
+ if branch_currency.get("attention") in (CURRENCY_ATTENTION_CLAIM,
2656
+ CURRENCY_ATTENTION_DECIDE,
2657
+ CURRENCY_ATTENTION_INSPECT,
2658
+ CURRENCY_ATTENTION_RECONCILE):
2659
+ return "branch-currency"
2660
+ if a.get("blocked_external"):
2661
+ return "blocked-external"
2662
+ if a.get("open_needs_human", 0):
2663
+ return "needs-human" # parked items block ready; surface them
2664
+ # A widened window is the agent's own judgment about evidence it looked at. When that evidence
2665
+ # moves, the judgment may no longer hold, so hand the decision back rather than sleeping out a
2666
+ # window armed against a state that has since changed. Movement is the whole trigger: unchanged
2667
+ # evidence never fires this, which is what keeps it from re-deciding something the agent already
2668
+ # settled. The engine reports that something moved and nothing more — whether the movement
2669
+ # resolved anything is the agent's call at the settle decision.
2670
+ if settle_seconds > DEFAULT_SETTLE_SECONDS and a.get("changed_this_tick"):
2671
+ return "review-evidence-moved"
2672
+ # merge-ready candidate: green, settled, nothing left in the attention set. Whether a review is
2673
+ # still on its way is the agent's judgment at the settle decision, from live GitHub evidence the
2674
+ # engine does not model; this wake hands it that decision rather than pre-empting it.
2675
+ # Interactive merge-ready does NOT require `all_checks_ok`'s "at least one observed check": a repo
2676
+ # with no configured checks has a CLEAN/MERGEABLE PR that should be callable ready. (That guard
2677
+ # stays in pipeline success, where a not-yet-created rollup must not read as a pass.)
2678
+ mergeability_certain = a.get("mergeability_certain")
2679
+ if mergeability_certain is None:
2680
+ mergeability_certain = _mergeability_certain(
2681
+ a.get("mergeable"), a.get("merge_state_status"), a.get("base"))
2682
+ if (mergeability_certain and a.get("mergeable") == "MERGEABLE"
2683
+ and a.get("merge_state_status") == "CLEAN"
2684
+ and a.get("checks_terminal") and not a.get("has_failing_checks")
2685
+ and a.get("checks_awaiting_approval", 0) == 0
2686
+ and a.get("branch_currency_blocker") is None
2687
+ and a.get("quiet_seconds", 0) >= settle_seconds):
2688
+ return "merge-ready"
2689
+ return None
2690
+
2691
+
2692
+ def _emit_wake(reason, **fields):
2693
+ print(json.dumps({"event": "BABYSIT_WAKE", "reason": reason, **fields}), flush=True)
2694
+
2695
+
2696
+ def _persisted_watch_generation(args):
2697
+ """Best-effort read of current ownership without creating or mutating watch state."""
2698
+ try:
2699
+ with open(os.path.join(args.state_dir, "state.json")) as f:
2700
+ state = json.load(f)
2701
+ except (OSError, json.JSONDecodeError):
2702
+ return None
2703
+ generation = state.get("watch_generation") if isinstance(state, dict) else None
2704
+ return generation if isinstance(generation, str) and generation else None
2705
+
2706
+
2707
+ def _blocker_sig(a):
2708
+ """Identity of blockers the agent surfaces once but cannot self-clear — parked needs-human
2709
+ items, a dispatched terminally-red check, and a fork workflow awaiting maintainer approval. The
2710
+ watch captures this at arm time and does not re-wake on a blocker already in that baseline, so a
2711
+ parked residual keeps the watch alive (or the blocked-external bounded watch keeps polling for
2712
+ the gate to clear) instead of busy-waking or terminating on the same condition."""
2713
+ sig = set(a.get("needs_human_ids") or [])
2714
+ if a.get("has_failing_checks") and a.get("checks_terminal") and not (a.get("counts") or {}).get("ci"):
2715
+ sig.add("__terminal_red__")
2716
+ if a.get("blocked_external"):
2717
+ sig.add("__blocked_external__")
2718
+ sig.add(("approval-review-state", a.get("review_decision")))
2719
+ if a.get("stack_blocker"):
2720
+ sig.add(f"__stack__:{a['stack_blocker']}")
2721
+ if a.get("base_ref_blocker"):
2722
+ sig.add(f"__base_ref__:{a['base_ref_blocker']}")
2723
+ if a.get("unrequested_base_merge"):
2724
+ sig.add(("unrequested-base-merge", a["unrequested_base_merge"].get("head")))
2725
+ branch_currency = a.get("branch_currency") or {}
2726
+ currency_disposition = branch_currency.get("disposition")
2727
+ if (currency_disposition == CURRENCY_CONFIRMED
2728
+ or (currency_disposition == CURRENCY_CLAIMED
2729
+ and not branch_currency.get("attention"))):
2730
+ sig.add(("currency", branch_currency.get("key"), currency_disposition,
2731
+ branch_currency.get("recovery_state")))
2732
+ # A new head is "context materially changed": a human may have pushed a commit that answers or
2733
+ # supersedes a parked residual, so the head is part of the baseline — when it moves, the residual
2734
+ # is no longer "already-surfaced against this state" and the watch wakes to give the agent a tick
2735
+ # to reopen/reprocess it, instead of parking forever while it still blocks merge-ready.
2736
+ if sig:
2737
+ sig.add(("head", a.get("head_sha")))
2738
+ return frozenset(sig)
2739
+
2740
+
2741
+ def _process_identity(pid):
2742
+ """Best-effort PID-reuse guard for replacing a prior watcher. If process identity cannot be
2743
+ proven, generation invalidation still suppresses its wake and we leave OS cleanup alone."""
2744
+ if IS_WINDOWS:
2745
+ return _win_process_identity(pid)
2746
+ try:
2747
+ r = subprocess.run(["ps", "-p", str(pid), "-o", "lstart=", "-o", "command="],
2748
+ capture_output=True, text=True, encoding="utf-8")
2749
+ except OSError:
2750
+ return None # no ps on PATH: unproven identity, same as a failed lookup
2751
+ if r.returncode != 0:
2752
+ return None
2753
+ return (r.stdout or "").strip() or None
2754
+
2755
+
2756
+ def _watch_candidate_path(args):
2757
+ return os.path.join(args.state_dir, "watch-candidate.json")
2758
+
2759
+
2760
+ def _read_watch_candidate(args):
2761
+ try:
2762
+ with open(_watch_candidate_path(args)) as f:
2763
+ candidate = json.load(f)
2764
+ except (FileNotFoundError, json.JSONDecodeError):
2765
+ return {}
2766
+ return candidate if isinstance(candidate, dict) else {}
2767
+
2768
+
2769
+ def _reserve_watch_candidate(args, generation):
2770
+ """Make this invocation the newest candidate without displacing the active watcher."""
2771
+ candidate = {
2772
+ "generation": generation,
2773
+ "pid": os.getpid(),
2774
+ "process_identity": _process_identity(os.getpid()),
2775
+ }
2776
+ with _state_lock(args.state_dir, exclusive=True):
2777
+ previous = _read_watch_candidate(args)
2778
+ tmp = tempfile.NamedTemporaryFile("w", dir=args.state_dir, delete=False)
2779
+ json.dump(candidate, tmp)
2780
+ tmp.flush()
2781
+ os.fsync(tmp.fileno())
2782
+ tmp.close()
2783
+ _replace_atomic(tmp.name, _watch_candidate_path(args))
2784
+ return previous
2785
+
2786
+
2787
+ def _clear_watch_candidate(args, generation):
2788
+ with _state_lock(args.state_dir, exclusive=True):
2789
+ if _read_watch_candidate(args).get("generation") != generation:
2790
+ return
2791
+ try:
2792
+ os.unlink(_watch_candidate_path(args))
2793
+ except FileNotFoundError:
2794
+ pass
2795
+
2796
+
2797
+ def _activate_watch(args, generation, now, cur):
2798
+ """Atomically activate this watcher and persist its successfully fetched preflight."""
2799
+ identity = _process_identity(os.getpid())
2800
+ with locked_state(args.state_dir, args.pr, args.repo, now) as box:
2801
+ if _read_watch_candidate(args).get("generation") != generation:
2802
+ return None, None
2803
+ state = box["state"]
2804
+ previous = {
2805
+ "pid": state.get("watch_pid"),
2806
+ "process_identity": state.get("watch_process_identity"),
2807
+ }
2808
+ state["watch_generation"] = generation
2809
+ state["watch_pid"] = os.getpid()
2810
+ state["watch_process_identity"] = identity
2811
+ # The arming poll is a real in-session watch poll: accumulate any suspended span since the
2812
+ # last activity (e.g. the watch armed right after the machine resumed) before reading elapsed.
2813
+ if state.get("started_at"):
2814
+ _advance_activity(state, now, accumulate=True)
2815
+ actionable, box["state"] = _apply_snapshot(box, args, cur, now, advance_trajectory=False)
2816
+ try:
2817
+ os.unlink(_watch_candidate_path(args))
2818
+ except FileNotFoundError:
2819
+ pass
2820
+ return previous, actionable
2821
+
2822
+
2823
+ def _watch_is_current(args, generation):
2824
+ """Read the ownership generation under the state lock without rewriting state.json."""
2825
+ state_path = os.path.join(args.state_dir, "state.json")
2826
+ with _state_lock(args.state_dir, exclusive=False):
2827
+ if not os.path.exists(state_path):
2828
+ return False
2829
+ with open(state_path) as f:
2830
+ return json.load(f).get("watch_generation") == generation
2831
+
2832
+
2833
+ def _emit_wake_if_current(args, generation, reason, **fields):
2834
+ """Serialize the final ownership check with takeover so an old generation cannot emit after
2835
+ the new generation has become current. Invocation replacement also supersedes every ordinary
2836
+ wake from the old budget, even when the replacement kept the same watch generation."""
2837
+ state_path = os.path.join(args.state_dir, "state.json")
2838
+ with _state_lock(args.state_dir, exclusive=False):
2839
+ if not os.path.exists(state_path):
2840
+ return False
2841
+ with open(state_path) as f:
2842
+ state = json.load(f)
2843
+ if state.get("watch_generation") != generation:
2844
+ return False
2845
+ current_invocation_id = state.get("invocation_id")
2846
+ watcher_invocation_id = getattr(args, "invocation_id", None)
2847
+ if current_invocation_id != watcher_invocation_id and reason != "invocation-superseded":
2848
+ _emit_wake(
2849
+ "invocation-superseded", watch_generation=generation,
2850
+ superseded_invocation_id=watcher_invocation_id,
2851
+ current_invocation_id=current_invocation_id,
2852
+ )
2853
+ return True
2854
+ _emit_wake(reason, watch_generation=generation, **fields)
2855
+ return True
2856
+
2857
+
2858
+ def _terminate_replaced_watch(previous):
2859
+ """Promptly stop the replaced process, but never signal a PID whose identity no longer matches.
2860
+
2861
+ On Windows os.kill maps to TerminateProcess, so the replaced watcher dies abruptly instead of
2862
+ unwinding a SIGTERM handler: it cannot clear its own watch candidate, nor finish terminating a
2863
+ predecessor it was mid-handoff with. Neither leaks. The replacement has already written its own
2864
+ candidate before calling this, and any watcher left running reads a foreign watch_generation on
2865
+ its next poll and exits on its own — so the abrupt path costs at most one poll interval."""
2866
+ pid = previous.get("pid")
2867
+ identity = previous.get("process_identity")
2868
+ if not isinstance(pid, int) or pid == os.getpid() or not identity:
2869
+ return
2870
+ if _process_identity(pid) != identity:
2871
+ return
2872
+ try:
2873
+ os.kill(pid, signal.SIGTERM)
2874
+ except OSError:
2875
+ pass # exited between the identity check and here (Windows reports this as a plain OSError)
2876
+
2877
+
2878
+ def _downstack_targets(args):
2879
+ """(pr, fetch_file_or_None) for each --downstack-pr; test fixtures bind a file per PR."""
2880
+ files = {}
2881
+ for spec in (getattr(args, "downstack_fetch_file", None) or []):
2882
+ pr_text, _, path = spec.partition("=")
2883
+ if pr_text.isdigit() and path:
2884
+ files[int(pr_text)] = path
2885
+ return [(pr, files.get(pr)) for pr in (getattr(args, "downstack_pr", None) or [])]
2886
+
2887
+
2888
+ def _downstack_sig(args):
2889
+ """Stateless signature of the lower stack layers built from the same identities the engine
2890
+ uses to decide that a layer has work: the head, each unresolved thread's remote-truth identity
2891
+ (`_thread_identity` — a reply or an edit both change it), each non-thread comment by id (bodies
2892
+ are rewritten by status bots and are not new work), failing checks by the canonical `FAILING`
2893
+ set, the merge state when it demands maintenance, the manager classification tuple, and the PR
2894
+ state. The watcher captures it at arm time and wakes `downstack-actionable` only when it grows,
2895
+ so already-parked or dispatched lower-layer items do not re-wake, but any observation that would
2896
+ make a lower layer non-quiescent does."""
2897
+ sig = set()
2898
+ for pr, fetch_file in _downstack_targets(args):
2899
+ try:
2900
+ if fetch_file:
2901
+ with open(fetch_file) as f:
2902
+ cur = json.load(f)
2903
+ else:
2904
+ cur = fetch(pr, args.repo, light=True)
2905
+ except (OSError, ValueError, SystemExit):
2906
+ continue # a failed lower-layer probe never wakes or blocks; the next poll retries
2907
+ head = cur.get("head_sha")
2908
+ sig.add((pr, "head", head))
2909
+ sig.add((pr, "state", cur.get("pr_state")))
2910
+ for t in cur.get("threads") or []:
2911
+ sig.add((pr, "thread", t.get("thread_id"), _thread_identity(t)))
2912
+ for c in cur.get("feedback") or []:
2913
+ sig.add((pr, "comment", c.get("id")))
2914
+ for c in cur.get("checks") or []:
2915
+ if (c.get("status") or "").upper() == "COMPLETED" and (c.get("conclusion") or "").upper() in FAILING:
2916
+ sig.add((pr, "check", head, c.get("key")))
2917
+ status = (cur.get("merge_state_status") or "").upper()
2918
+ if status in ("DIRTY", "BEHIND"):
2919
+ sig.add((pr, "currency", status, head))
2920
+ chain = cur.get("pr_chain") if isinstance(cur.get("pr_chain"), dict) else {}
2921
+ sig.add((pr, "manager", chain.get("manager_status"), chain.get("target_needs_rebase")))
2922
+ return frozenset(sig)
2923
+
2924
+
2925
+ def cmd_watch(args):
2926
+ """Deterministic background change-detector (no agent tokens between changes): poll on an
2927
+ interval, print one wake sentinel line and exit when there is something for the agent to do
2928
+ (work to inspect or a stop condition), or exit on the stop-signal file / max-runtime.
2929
+ A residual already present at arm time (a parked needs-human, or a dispatched terminal-red the
2930
+ agent already handed back) does NOT re-wake the loop — it keeps watching the other streams;
2931
+ only a *new* blocker (signature grown past the baseline) wakes."""
2932
+ stop_requested = threading.Event()
2933
+ interrupt_immediately = True
2934
+ superseded = False
2935
+ generation = uuid.uuid4().hex
2936
+
2937
+ # An already-requested stop is authoritative before this invocation becomes a candidate. Use
2938
+ # current ownership for the wake when one exists, but do not fetch, reserve, mutate watch state,
2939
+ # or disturb that incumbent merely to report the stop condition.
2940
+ if args.stop_file and os.path.exists(args.stop_file):
2941
+ _emit_wake("stop-signal", watch_generation=_persisted_watch_generation(args) or generation)
2942
+ return
2943
+
2944
+ def request_stop(_signum, _frame):
2945
+ nonlocal superseded
2946
+ superseded = True
2947
+ stop_requested.set()
2948
+ if interrupt_immediately:
2949
+ raise _WatchSuperseded()
2950
+
2951
+ # Cooperative supersession. Windows accepts this handler but never delivers SIGTERM across
2952
+ # processes, so there it is inert and the loop's own watch_generation checks are what retire a
2953
+ # replaced watcher — the handler is the fast path, not the correctness guarantee.
2954
+ prior_sigterm = signal.getsignal(signal.SIGTERM)
2955
+ signal.signal(signal.SIGTERM, request_stop)
2956
+ try:
2957
+ previous_candidate = _reserve_watch_candidate(args, generation)
2958
+ _terminate_replaced_watch(previous_candidate)
2959
+
2960
+ # Preflight before takeover: an invalid fetch/auth/config must not displace a healthy watcher.
2961
+ cur = _fetch_snapshot(args)
2962
+ if stop_requested.is_set():
2963
+ return
2964
+
2965
+ # Finish the handoff even if an even newer watcher arrives: otherwise that watcher could
2966
+ # stop us between activation and terminating our predecessor, orphaning the oldest process.
2967
+ interrupt_immediately = False
2968
+ previous, actionable = _activate_watch(args, generation, _now(), cur)
2969
+ if previous is None:
2970
+ superseded = True
2971
+ return
2972
+ _terminate_replaced_watch(previous)
2973
+ interrupt_immediately = True
2974
+ if stop_requested.is_set():
2975
+ return
2976
+
2977
+ armed = _blocker_sig(actionable) # blockers already surfaced when this generation armed
2978
+ downstack_targets = _downstack_targets(args)
2979
+ downstack_armed = _downstack_sig(args) if downstack_targets else frozenset()
2980
+ while True:
2981
+ if stop_requested.is_set():
2982
+ return
2983
+ if not _watch_is_current(args, generation):
2984
+ superseded = True
2985
+ return
2986
+ if args.stop_file and os.path.exists(args.stop_file):
2987
+ _emit_wake_if_current(args, generation, "stop-signal")
2988
+ return
2989
+ now = _now()
2990
+ # Active elapsed = raw wall-clock minus accumulated suspended time (dead_time was
2991
+ # updated by the previous poll's _run_snapshot, including any gap from a resumed
2992
+ # suspension). The 8h budget spends active time; the 3-day backstop is raw wall-clock.
2993
+ wall_elapsed = _elapsed(actionable.get("invocation_started_at"), now)
2994
+ dead_time = int(actionable.get("invocation_dead_time_seconds") or 0)
2995
+ invocation_elapsed = max(0, wall_elapsed - dead_time)
2996
+ budget = actionable.get("invocation_budget_seconds") or 0
2997
+ backstop = actionable.get("invocation_backstop_seconds") or 0
2998
+ actionable["invocation_elapsed_seconds"] = invocation_elapsed
2999
+ actionable["invocation_remaining_seconds"] = max(0, budget - invocation_elapsed)
3000
+ actionable["invocation_wall_elapsed_seconds"] = wall_elapsed
3001
+ actionable["persisted_state_age_seconds"] = _elapsed(
3002
+ actionable.get("persisted_state_created_at"), now)
3003
+ reason = _wake_reason(
3004
+ actionable, args.settle_seconds,
3005
+ getattr(args, "feedback_coalesce_seconds", FEEDBACK_COALESCE_SECONDS))
3006
+ if reason in ("terminal", "merge-ready"):
3007
+ _emit_wake_if_current(args, generation, reason, url=actionable.get("url"),
3008
+ pr_state=actionable.get("pr_state"), counts=actionable.get("counts"))
3009
+ return
3010
+ active_cap_hit = bool(budget) and invocation_elapsed >= budget
3011
+ backstop_hit = bool(backstop) and wall_elapsed >= backstop
3012
+ if active_cap_hit or backstop_hit:
3013
+ _emit_wake_if_current(
3014
+ args, generation, "max-runtime", url=actionable.get("url"),
3015
+ invocation_id=actionable.get("invocation_id"),
3016
+ invocation_started_at=actionable.get("invocation_started_at"),
3017
+ invocation_elapsed_seconds=actionable.get("invocation_elapsed_seconds"),
3018
+ invocation_budget_seconds=budget,
3019
+ invocation_wall_elapsed_seconds=wall_elapsed,
3020
+ invocation_backstop_seconds=backstop or None,
3021
+ max_runtime_ceiling=("backstop" if backstop_hit and not active_cap_hit
3022
+ else "active-budget"),
3023
+ persisted_state_age_seconds=actionable.get("persisted_state_age_seconds"),
3024
+ )
3025
+ return
3026
+ drain_seconds = getattr(args, "blocked_external_drain_seconds", None)
3027
+ drain_quiet = actionable.get("blocked_external_review_quiet_seconds", 0)
3028
+ if (drain_seconds is not None and actionable.get("blocked_external")
3029
+ and drain_quiet >= drain_seconds):
3030
+ _emit_wake_if_current(
3031
+ args, generation, "blocked-external-drained", url=actionable.get("url"),
3032
+ pr_state=actionable.get("pr_state"), counts=actionable.get("counts"),
3033
+ blocked_external_review_quiet_seconds=drain_quiet,
3034
+ blocked_external_drain_seconds=drain_seconds,
3035
+ )
3036
+ return
3037
+ approval_review_moved = (
3038
+ reason == "blocked-external"
3039
+ and actionable.get("blocked_external_review_moved_this_tick"))
3040
+ if (reason in ("needs-human", "blocked-failing", "blocked-external",
3041
+ "stack-blocked", "base-ref-blocked", "unrequested-base-merge")
3042
+ and not approval_review_moved and _blocker_sig(actionable) <= armed):
3043
+ reason = None # already-surfaced residual — keep watching, do not re-wake or terminate
3044
+ if reason:
3045
+ _emit_wake_if_current(args, generation, reason, url=actionable.get("url"),
3046
+ pr_state=actionable.get("pr_state"), counts=actionable.get("counts"))
3047
+ return
3048
+ if downstack_targets:
3049
+ grown = _downstack_sig(args) - downstack_armed
3050
+ if grown:
3051
+ _emit_wake_if_current(
3052
+ args, generation, "downstack-actionable", url=actionable.get("url"),
3053
+ pr_state=actionable.get("pr_state"), counts=actionable.get("counts"),
3054
+ downstack_prs=sorted({item[0] for item in grown}))
3055
+ return
3056
+ remaining = max(0, budget - invocation_elapsed) if budget else args.interval
3057
+ wait_seconds = min(args.interval, remaining)
3058
+ if stop_requested.wait(wait_seconds):
3059
+ return
3060
+ if not _watch_is_current(args, generation):
3061
+ superseded = True
3062
+ return
3063
+ actionable = _run_snapshot(args, _now(), advance_trajectory=False,
3064
+ watch_generation=generation)
3065
+ except _WatchSuperseded:
3066
+ superseded = True
3067
+ return
3068
+ except _InvocationSuperseded as exc:
3069
+ superseded = True
3070
+ _emit_wake_if_current(
3071
+ args, generation, "invocation-superseded",
3072
+ superseded_invocation_id=getattr(args, "invocation_id", None),
3073
+ current_invocation_id=exc.current_invocation_id,
3074
+ )
3075
+ return
3076
+ finally:
3077
+ interrupt_immediately = False
3078
+ _clear_watch_candidate(args, generation)
3079
+ # A newer owner can still signal our recorded PID after observing us stale but before this
3080
+ # process has exited. Keep that late takeover signal harmless; ordinary wake/timeout/stop
3081
+ # returns restore the embedding caller's handler as before.
3082
+ signal.signal(signal.SIGTERM, signal.SIG_IGN if superseded else prior_sigterm)
3083
+
3084
+
3085
+ def _mark_thread_records(item_ids, args, state):
3086
+ """Best-effort current records for every thread covered by one atomic mark."""
3087
+ wanted = set(item_ids)
3088
+ if not wanted:
3089
+ return {}
3090
+ try:
3091
+ if getattr(args, "fetch_file", None):
3092
+ with open(args.fetch_file) as f:
3093
+ threads = json.load(f).get("threads", [])
3094
+ else:
3095
+ owner, name, host = _resolve_repo_ref(args.repo, (state.get("pr") or {}).get("url"))
3096
+ threads = fetch_threads(args.pr, owner, name, host)
3097
+ except (SystemExit, Exception): # SystemExit (from _run_checked) is not an Exception subclass
3098
+ return {}
3099
+ return {
3100
+ thread.get("thread_id"): thread
3101
+ for thread in threads if thread.get("thread_id") in wanted
3102
+ }
3103
+
3104
+
3105
+ def _mark_thread_baselines(item_ids, args, state):
3106
+ """Best-effort current identities for every thread covered by one atomic mark."""
3107
+ return {
3108
+ thread_id: [thread.get("last_comment_id"), thread.get("last_comment_at")]
3109
+ for thread_id, thread in _mark_thread_records(item_ids, args, state).items()
3110
+ }
3111
+
3112
+
3113
+ def _mark_thread_baseline(item_id, args, state):
3114
+ """Compatibility wrapper for an ordinary single-thread dispatched mark."""
3115
+ return _mark_thread_baselines([item_id], args, state).get(item_id)
3116
+
3117
+
3118
+ def _mark_branch_currency(args, state, now):
3119
+ currency = _load_branch_currency_state(state)
3120
+ key = args.currency_key
3121
+ if not key or currency.get("current_key") != key:
3122
+ raise SystemExit("currency mark requires the exact current observation key")
3123
+ item = currency.get("items", {}).get(key)
3124
+ if not isinstance(item, dict):
3125
+ raise SystemExit("currency mark requires a current observed item")
3126
+ prior = item.get("disposition", DISPOSITION_OPEN)
3127
+ outcome = args.currency_outcome
3128
+ inspected_fingerprint = args.currency_inspected_fingerprint
3129
+
3130
+ if outcome:
3131
+ if prior != CURRENCY_CLAIMED:
3132
+ raise SystemExit("currency outcomes require a claimed observation")
3133
+ item["reconciled_invocation_id"] = state.get("invocation_id")
3134
+ item["reconciled_at"] = _iso(now)
3135
+ if outcome == CURRENCY_OUTCOME_MUTATION_OBSERVED:
3136
+ item["mutation_consumed"] = True
3137
+ item["mutation_observed_at"] = _iso(now)
3138
+ item["recovery_state"] = CURRENCY_OUTCOME_MUTATION_OBSERVED
3139
+ elif outcome == CURRENCY_OUTCOME_AMBIGUOUS:
3140
+ item["recovery_state"] = CURRENCY_OUTCOME_AMBIGUOUS
3141
+ elif outcome == CURRENCY_OUTCOME_PROVEN_NO_MUTATION:
3142
+ if item.get("mutation_consumed"):
3143
+ raise SystemExit("cannot record no mutation after mutation start was observed")
3144
+ retries = int(item.get("retry_count", 0))
3145
+ if retries < 1:
3146
+ item["retry_count"] = retries + 1
3147
+ item["disposition"] = DISPOSITION_OPEN
3148
+ item["recovery_state"] = "retry-authorized"
3149
+ item["retry_not_before"] = _iso(
3150
+ now + timedelta(seconds=CURRENCY_RETRY_BACKOFF_SECONDS))
3151
+ item.pop("claimed_invocation_id", None)
3152
+ item.pop("reconciled_invocation_id", None)
3153
+ else:
3154
+ item["disposition"] = DISPOSITION_OPEN
3155
+ item["recovery_state"] = "retry-exhausted"
3156
+ state["last_action"] = f"{outcome} currency {key}"
3157
+ return item.get("recovery_state") in (
3158
+ CURRENCY_OUTCOME_AMBIGUOUS, "retry-exhausted")
3159
+
3160
+ if inspected_fingerprint:
3161
+ if prior != DISPOSITION_OPEN:
3162
+ raise SystemExit("currency inspection requires an open observation")
3163
+ parks = currency.get("semantic_parks") or {}
3164
+ if not parks:
3165
+ raise SystemExit("currency inspection requires carried semantic conflict evidence")
3166
+ item["inspected_semantic_conflict_fingerprint"] = inspected_fingerprint
3167
+ item["inspected_at"] = _iso(now)
3168
+ if inspected_fingerprint in parks:
3169
+ item["semantic_conflict_fingerprint"] = inspected_fingerprint
3170
+ item["recovery_state"] = "semantic-unchanged"
3171
+ item["inspection_result"] = "unchanged"
3172
+ else:
3173
+ item["inspection_required"] = False
3174
+ item["inspection_result"] = "changed"
3175
+ item["recovery_state"] = "semantic-changed"
3176
+ item.pop("semantic_conflict_fingerprint", None)
3177
+ # The preview describes the current conflict set for this head. Once it differs, the
3178
+ # carried fingerprints are no longer standing residuals and must not tax every later
3179
+ # base generation with inspection of stale evidence.
3180
+ currency["semantic_parks"] = {}
3181
+ item["parked_semantic_fingerprints"] = []
3182
+ state["last_action"] = f"inspected currency {key}"
3183
+ return inspected_fingerprint in parks
3184
+
3185
+ disposition = args.currency_disposition
3186
+ allowed = {
3187
+ DISPOSITION_OPEN: {DISPOSITION_OPEN, CURRENCY_CLAIMED},
3188
+ CURRENCY_CLAIMED: {CURRENCY_CLAIMED, CURRENCY_CONFIRMED},
3189
+ CURRENCY_CONFIRMED: {CURRENCY_CONFIRMED, DISPOSITION_OPEN},
3190
+ }
3191
+ if disposition not in allowed.get(prior, set()):
3192
+ raise SystemExit(f"invalid currency transition: {prior} -> {disposition}")
3193
+ if (prior == DISPOSITION_OPEN and disposition == CURRENCY_CLAIMED
3194
+ and item.get("inspection_required")):
3195
+ raise SystemExit("currency claim requires semantic-fingerprint inspection first")
3196
+ if (prior == DISPOSITION_OPEN and disposition == CURRENCY_CLAIMED
3197
+ and _needs_human_source_is_covered(state, key, "currency")):
3198
+ raise SystemExit("currency claim requires the current human decision to be answered")
3199
+ if (prior == DISPOSITION_OPEN and disposition == CURRENCY_CLAIMED
3200
+ and _currency_mutation_requires_answer(state, key, item)):
3201
+ raise SystemExit("currency claim requires an exact human answer")
3202
+ if prior == DISPOSITION_OPEN and disposition == CURRENCY_CLAIMED:
3203
+ retry_not_before = item.get("retry_not_before")
3204
+ if retry_not_before:
3205
+ try:
3206
+ if _parse_iso8601(retry_not_before) > now:
3207
+ raise SystemExit("currency retry backoff has not elapsed")
3208
+ except (ValueError, TypeError):
3209
+ raise SystemExit("currency retry backoff is invalid")
3210
+ budget = state.get("invocation_budget_seconds")
3211
+ backstop = state.get("invocation_backstop_seconds")
3212
+ if ((budget is not None and _active_elapsed(state, now) >= budget)
3213
+ or (backstop is not None and _elapsed(state.get("started_at"), now) >= backstop)):
3214
+ raise SystemExit("currency claim cannot start after max-runtime")
3215
+ item["disposition"] = disposition
3216
+ item["transitioned_at"] = _iso(now)
3217
+ if disposition == CURRENCY_CLAIMED:
3218
+ # A repeated claim is deliberately idempotent. It cannot transfer or renew an existing
3219
+ # claim; a later invocation must reconcile it and record an explicit outcome.
3220
+ if prior == DISPOSITION_OPEN:
3221
+ item["mutation_requires_answer"] = False
3222
+ item["claimed_invocation_id"] = state.get("invocation_id")
3223
+ item["claimed_at"] = _iso(now)
3224
+ item["attempt_number"] = int(item.get("retry_count", 0)) + 1
3225
+ item["mutation_consumed"] = False
3226
+ item["recovery_state"] = "claimed"
3227
+ item.pop("reconciled_invocation_id", None)
3228
+ elif disposition == CURRENCY_CONFIRMED:
3229
+ item["confirmed_invocation_id"] = state.get("invocation_id")
3230
+ elif disposition == DISPOSITION_OPEN:
3231
+ _reactivate_currency_item(currency, item)
3232
+ else:
3233
+ item["parked_semantic_fingerprints"] = sorted(currency.get("semantic_parks") or {})
3234
+ state["last_action"] = f"{disposition} currency {key}"
3235
+ return False
3236
+
3237
+
3238
+ def _load_needs_human_residual(path, item_id=None, expected_kind=None):
3239
+ if not path:
3240
+ raise SystemExit("needs-human marks require --residual-file")
3241
+ try:
3242
+ with open(path, encoding="utf-8") as f:
3243
+ residual = json.load(f)
3244
+ except (OSError, json.JSONDecodeError) as exc:
3245
+ raise SystemExit(f"cannot read --residual-file: {exc}")
3246
+ error = _needs_human_residual_error(residual)
3247
+ if error:
3248
+ raise SystemExit(f"invalid --residual-file: {error}")
3249
+ if item_id is not None and not any(
3250
+ source["id"] == item_id and source["kind"] == expected_kind
3251
+ for source in residual["sources"]):
3252
+ raise SystemExit("--residual-file sources must include the marked item ID and kind")
3253
+ return residual
3254
+
3255
+
3256
+ def _load_human_answer(path):
3257
+ if not path:
3258
+ raise SystemExit("--answer-decision requires --answer-file")
3259
+ try:
3260
+ with open(path, encoding="utf-8") as f:
3261
+ answer = f.read().strip()
3262
+ except OSError as exc:
3263
+ raise SystemExit(f"cannot read --answer-file: {exc}")
3264
+ if not answer:
3265
+ raise SystemExit("--answer-file must contain the human's answer")
3266
+ return answer
3267
+
3268
+
3269
+ def _park_human_decision(state, residual, args, now):
3270
+ """Freeze one complete decision over ordinary source observations, then publish it."""
3271
+ threads = state.get("threads", {})
3272
+ feedback = state.get("feedback", {})
3273
+ checks = state.get("checks", {})
3274
+ decisions = state.setdefault("human_decisions", [])
3275
+ existing = next((
3276
+ decision for decision in decisions
3277
+ if isinstance(decision, dict)
3278
+ and _residual_identity(decision.get("residual")) == _residual_identity(residual)
3279
+ ), None)
3280
+ if existing is not None:
3281
+ _install_human_decisions(state, decisions)
3282
+ return existing
3283
+ covered = {
3284
+ (source.get("kind"), source.get("id"))
3285
+ for decision in decisions if isinstance(decision, dict)
3286
+ for source in decision.get("sources") or [] if isinstance(source, dict)
3287
+ }
3288
+ overlap = covered.intersection(_source_identity(source) for source in residual["sources"])
3289
+ if overlap:
3290
+ raise SystemExit("needs-human source already belongs to another current decision")
3291
+
3292
+ for source in residual["sources"]:
3293
+ kind, source_id = _source_identity(source)
3294
+ if not _source_can_enter_human_decision(
3295
+ state, kind, source_id, threads, feedback, checks):
3296
+ raise SystemExit(f"needs-human source is not current decision evidence: {kind} {source_id}")
3297
+ if kind == "currency" and args.semantic_conflict_fingerprint:
3298
+ currency = _load_branch_currency_state(state)
3299
+ item = currency["items"][source_id]
3300
+ fingerprint = args.semantic_conflict_fingerprint
3301
+ item["semantic_conflict_fingerprint"] = fingerprint
3302
+ currency.setdefault("semantic_parks", {})[fingerprint] = {
3303
+ "head_sha": item.get("head_sha"),
3304
+ "status": item.get("status"),
3305
+ "route": item.get("route"),
3306
+ "observation_key": source_id,
3307
+ }
3308
+
3309
+ thread_ids = [
3310
+ source["id"] for source in residual["sources"] if source["kind"] == "thread"]
3311
+ thread_records = _mark_thread_records(thread_ids, args, state)
3312
+ thread_baselines = {
3313
+ thread_id: [thread.get("last_comment_id"), thread.get("last_comment_at")]
3314
+ for thread_id, thread in thread_records.items()
3315
+ }
3316
+ missing_thread_baselines = sorted(set(thread_ids) - set(thread_baselines))
3317
+ if missing_thread_baselines:
3318
+ raise SystemExit(
3319
+ "cannot freeze the post-reply thread observation: "
3320
+ + ", ".join(missing_thread_baselines))
3321
+ authoritative_thread_urls = {
3322
+ thread_id: thread.get("url") for thread_id, thread in thread_records.items()
3323
+ }
3324
+ if (any(not isinstance(url, str) or not url.strip()
3325
+ for url in authoritative_thread_urls.values())
3326
+ or set(residual["thread_urls"]) != set(authoritative_thread_urls.values())):
3327
+ raise SystemExit("thread_urls must match the authoritative URL of every thread source")
3328
+ sources = []
3329
+ for source in residual["sources"]:
3330
+ kind, source_id = _source_identity(source)
3331
+ if kind == "thread":
3332
+ observation = thread_baselines[source_id]
3333
+ elif kind in ("comment", "review"):
3334
+ observation = _current_source_observation(
3335
+ state, kind, source_id, threads, feedback, checks)
3336
+ if args.comment == source_id and args.acted_edit_id:
3337
+ observation = [args.acted_edit_id]
3338
+ else:
3339
+ observation = _current_source_observation(
3340
+ state, kind, source_id, threads, feedback, checks)
3341
+ if observation is None:
3342
+ raise SystemExit(f"cannot freeze needs-human observation: {kind} {source_id}")
3343
+ sources.append({"kind": kind, "id": source_id, "observation": observation})
3344
+
3345
+ if any(
3346
+ _answered_human_source_matches(
3347
+ state, source["kind"], source["id"], source["observation"])
3348
+ for source in sources):
3349
+ raise SystemExit("source observations already have a recorded human answer")
3350
+ _release_ordinary_suppression_for_decision(state, sources, threads, feedback)
3351
+ decision = _decision_record(residual, sources, _iso(now))
3352
+ if any(item.get("id") == decision["id"] for item in decisions if isinstance(item, dict)):
3353
+ return decision
3354
+ decisions.append(decision)
3355
+ _install_human_decisions(state, decisions)
3356
+ return decision
3357
+
3358
+
3359
+ def _invalidate_human_source(state, item_id, kind):
3360
+ kept = [
3361
+ decision for decision in state.get("human_decisions") or []
3362
+ if not any(source.get("id") == item_id and source.get("kind") == kind
3363
+ for source in decision.get("sources") or [] if isinstance(source, dict))
3364
+ ]
3365
+ _replace_human_decisions(state, kept)
3366
+
3367
+
3368
+ def _needs_human_source_is_covered(state, item_id, kind):
3369
+ return any(
3370
+ source.get("id") == item_id and source.get("kind") == kind
3371
+ for decision in state.get("human_decisions") or []
3372
+ if isinstance(decision, dict)
3373
+ for source in decision.get("sources") or []
3374
+ if isinstance(source, dict)
3375
+ )
3376
+
3377
+
3378
+ def _answer_human_decision(state, decision_id, answer_file, now):
3379
+ answer = _load_human_answer(answer_file)
3380
+ current = next((
3381
+ decision for decision in state.get("human_decisions") or []
3382
+ if isinstance(decision, dict) and decision.get("id") == decision_id
3383
+ ), None)
3384
+ if current is None:
3385
+ existing = next((
3386
+ decision for decision in state.get("answered_human_decisions") or []
3387
+ if isinstance(decision, dict) and decision.get("id") == decision_id
3388
+ ), None)
3389
+ if existing and existing.get("answer") == answer:
3390
+ return existing
3391
+ raise SystemExit("--answer-decision must name an exact current decision ID")
3392
+ kept = [
3393
+ decision for decision in state.get("human_decisions") or []
3394
+ if not isinstance(decision, dict) or decision.get("id") != decision_id
3395
+ ]
3396
+ _replace_human_decisions(state, kept)
3397
+ answered = {**json.loads(json.dumps(current)), "answer": answer, "answered_at": _iso(now)}
3398
+ state.setdefault("answered_human_decisions", []).append(answered)
3399
+ state["last_action"] = f"answered human decision {decision_id}"
3400
+ return answered
3401
+
3402
+
3403
+ def cmd_mark(args):
3404
+ now = _now()
3405
+ marked = None
3406
+ with locked_state(args.state_dir, args.pr, args.repo, now) as box:
3407
+ _apply_invocation(box, args, now)
3408
+ if (args.check or args.thread or args.comment) and args.residual_file \
3409
+ and args.disposition != DISPOSITION_NEEDS_HUMAN:
3410
+ raise SystemExit("--residual-file requires --disposition needs-human for source marks")
3411
+ if (args.disposition == DISPOSITION_NEEDS_HUMAN and not args.residual_file
3412
+ and not (args.check or args.thread or args.comment)):
3413
+ raise SystemExit("needs-human marks require --residual-file")
3414
+ state = box["state"]
3415
+ # An agent-driven mark is activity: bump the heartbeat (no accumulation) so a long tick
3416
+ # that only marks — never snapshots — still never has its active time refunded as dead time.
3417
+ if state.get("started_at"):
3418
+ _advance_activity(state, now, accumulate=False)
3419
+ if args.answer_decision or args.answer_file:
3420
+ if not args.answer_decision or not args.answer_file:
3421
+ raise SystemExit("answer marks require --answer-decision and --answer-file")
3422
+ if any((
3423
+ args.check,
3424
+ args.thread,
3425
+ args.comment,
3426
+ args.residual_file,
3427
+ args.acted_edit_id,
3428
+ args.currency_key,
3429
+ args.currency_disposition,
3430
+ args.semantic_conflict_fingerprint,
3431
+ args.currency_outcome,
3432
+ args.currency_inspected_fingerprint,
3433
+ args.disposition != DISPOSITION_DISPATCHED,
3434
+ )):
3435
+ raise SystemExit("answer marks cannot be combined with source actions")
3436
+ marked = _answer_human_decision(
3437
+ state, args.answer_decision, args.answer_file, now)
3438
+ elif (args.currency_key or args.currency_disposition or args.currency_outcome
3439
+ or args.currency_inspected_fingerprint):
3440
+ actions = sum(bool(value) for value in (
3441
+ args.currency_disposition, args.currency_outcome,
3442
+ args.currency_inspected_fingerprint))
3443
+ if not args.currency_key or actions != 1:
3444
+ raise SystemExit("currency marks require --currency-key and exactly one currency action")
3445
+ decision_required = _mark_branch_currency(args, state, now)
3446
+ if decision_required:
3447
+ residual = _load_needs_human_residual(
3448
+ args.residual_file, args.currency_key, "currency")
3449
+ marked = _park_human_decision(state, residual, args, now)
3450
+ elif args.residual_file:
3451
+ raise SystemExit(
3452
+ "--residual-file requires a currency outcome that needs a human decision")
3453
+ if args.currency_disposition == DISPOSITION_OPEN:
3454
+ _invalidate_human_source(state, args.currency_key, "currency")
3455
+ marked = marked or args.currency_key
3456
+ elif args.residual_file and not (args.check or args.thread or args.comment):
3457
+ if args.disposition != DISPOSITION_NEEDS_HUMAN:
3458
+ raise SystemExit("a residual-only mark requires --disposition needs-human")
3459
+ residual = _load_needs_human_residual(args.residual_file)
3460
+ marked = _park_human_decision(state, residual, args, now)
3461
+ state["last_action"] = "needs-human residual"
3462
+ elif args.check:
3463
+ head = state.get("head_sha")
3464
+ if not head:
3465
+ raise SystemExit("mark --check requires a prior snapshot (state has no head_sha)")
3466
+ state.setdefault("ci_dispatched", {}).setdefault(head, [])
3467
+ if args.disposition == DISPOSITION_OPEN:
3468
+ _invalidate_human_source(state, args.check, "check")
3469
+ state["ci_dispatched"][head] = [
3470
+ key for key in state["ci_dispatched"][head] if key != args.check]
3471
+ elif args.disposition == DISPOSITION_NEEDS_HUMAN:
3472
+ residual = _load_needs_human_residual(
3473
+ args.residual_file, args.check, "check")
3474
+ marked = _park_human_decision(state, residual, args, now)
3475
+ else:
3476
+ if _needs_human_source_is_covered(state, args.check, "check"):
3477
+ raise SystemExit(
3478
+ "cannot dispatch a source covered by a current human decision")
3479
+ if args.check not in state["ci_dispatched"][head]:
3480
+ state["ci_dispatched"][head].append(args.check)
3481
+ state["last_action"] = f"{args.disposition} check {args.check}"
3482
+ marked = marked or args.check
3483
+ elif args.thread or args.comment:
3484
+ item_id = args.thread or args.comment
3485
+ collection, id_field, label = (
3486
+ ("threads", "thread_id", "thread") if args.thread else ("feedback", "id", "comment"))
3487
+ entry = state.setdefault(collection, {}).setdefault(item_id, {id_field: item_id})
3488
+ kind = "thread" if args.thread else entry.get("kind", "comment")
3489
+ if args.disposition == DISPOSITION_OPEN:
3490
+ entry["disposition"] = DISPOSITION_OPEN
3491
+ _invalidate_human_source(state, item_id, kind)
3492
+ entry.pop("acted_identity", None) # reopened -> next dispatch/park re-baselines
3493
+ elif args.disposition == DISPOSITION_NEEDS_HUMAN:
3494
+ residual = _load_needs_human_residual(
3495
+ args.residual_file, item_id, kind)
3496
+ marked = _park_human_decision(state, residual, args, now)
3497
+ else:
3498
+ if _needs_human_source_is_covered(state, item_id, kind):
3499
+ raise SystemExit(
3500
+ "cannot dispatch a source covered by a current human decision")
3501
+ entry["disposition"] = DISPOSITION_DISPATCHED
3502
+ if args.thread:
3503
+ # our reply moved the thread's last comment, so re-read it now as the baseline.
3504
+ ident = _mark_thread_baseline(item_id, args, state)
3505
+ if ident is not None:
3506
+ entry["acted_identity"] = ident
3507
+ elif args.comment and args.acted_edit_id:
3508
+ entry["acted_identity"] = [args.acted_edit_id]
3509
+ state["last_action"] = f"{args.disposition} {label} {item_id}"
3510
+ marked = marked or item_id
3511
+ if getattr(args, "invariant_key", None) and args.disposition == DISPOSITION_DISPATCHED:
3512
+ _record_invariant_round(state, args.invariant_key, state.get("head_sha"))
3513
+ if isinstance(marked, dict):
3514
+ print(json.dumps({"marked": marked.get("id"), "decision": marked}, separators=(",", ":")))
3515
+ else:
3516
+ print(json.dumps({"marked": marked}))
3517
+
3518
+
3519
+ WATCH_BOOTSTRAP_FLAGS = ("--state-dir", "--invocation-id", "--session-started-at",
3520
+ "--invocation-budget-seconds")
3521
+ WATCH_BOOTSTRAP_HINT = """\
3522
+ watch cannot start a babysit run: it only arms the change detector for an
3523
+ invocation that was already bootstrapped by a snapshot --start-invocation run
3524
+ (the ce-babysit-pr Step 2 bootstrap).
3525
+ To recover, invoke the ce-babysit-pr skill through your harness's callable
3526
+ skill mechanism with this PR: its instructions own the bootstrap, arming,
3527
+ marks, and stop protocol. Do not drive pr-snapshot directly outside that skill.
3528
+ Never mint the bootstrap values yourself; they come from the skill's
3529
+ bootstrap snapshot."""
3530
+
3531
+
3532
+ class _WatchHintingParser(argparse.ArgumentParser):
3533
+ # A bare `watch --pr N` is the observed illegal start path (an agent arming the
3534
+ # detector without the Step 2 bootstrap). Keep the fail-closed exit 2, but make
3535
+ # the refusal carry its own recovery path instead of a raw usage dump.
3536
+ def error(self, message):
3537
+ if (self.prog.endswith(" watch") and "required" in message
3538
+ and any(flag in message for flag in WATCH_BOOTSTRAP_FLAGS)):
3539
+ self.print_usage(sys.stderr)
3540
+ self.exit(2, f"{self.prog}: error: {message}\n\n{WATCH_BOOTSTRAP_HINT}\n")
3541
+ super().error(message)
3542
+
3543
+
3544
+ def main():
3545
+ p = argparse.ArgumentParser(prog="pr-snapshot")
3546
+ sub = p.add_subparsers(dest="cmd", required=True, parser_class=_WatchHintingParser)
3547
+
3548
+ s = sub.add_parser("snapshot")
3549
+ s.add_argument("--pr", type=int, required=True)
3550
+ s.add_argument("--repo", default=None)
3551
+ s.add_argument("--state-dir", required=True)
3552
+ s.add_argument("--fetch-file", default=None)
3553
+ sg = s.add_mutually_exclusive_group()
3554
+ sg.add_argument("--start-invocation", action="store_true",
3555
+ help="mint one new fixed budget (first snapshot only)")
3556
+ sg.add_argument("--reset-session", action="store_true",
3557
+ help=argparse.SUPPRESS) # deprecated alias for --start-invocation
3558
+ sg.add_argument("--continue-invocation", action="store_true",
3559
+ help="carry the current fixed budget into a managed-stack layer state dir")
3560
+ s.add_argument("--invocation-id", default=None,
3561
+ help="token emitted by the first snapshot; required on every resume")
3562
+ s.add_argument("--session-started-at", type=_session_started_at,
3563
+ help="fixed anchor emitted by the first snapshot; required for a new state dir")
3564
+ s.add_argument("--invocation-budget-seconds", type=float,
3565
+ help=f"fixed total budget; first snapshot default {DEFAULT_INVOCATION_BUDGET_SECONDS}")
3566
+ s.add_argument("--continue-dead-time-seconds", type=float, default=None,
3567
+ help="carry the prior layer's accumulated dead time into a managed-stack layer "
3568
+ "(with --continue-invocation), so the shared active-time budget stays correct")
3569
+ s.set_defaults(func=cmd_snapshot)
3570
+
3571
+ m = sub.add_parser("mark")
3572
+ m.add_argument("--pr", type=int, default=0)
3573
+ m.add_argument("--repo", default=None)
3574
+ m.add_argument("--state-dir", required=True)
3575
+ m.add_argument("--invocation-id", required=True,
3576
+ help="token emitted by the invocation's first snapshot")
3577
+ m.add_argument("--session-started-at", type=_session_started_at, required=True,
3578
+ help="fixed anchor emitted by the invocation's first snapshot")
3579
+ m.add_argument("--invocation-budget-seconds", type=float, required=True,
3580
+ help="fixed budget emitted by the invocation's first snapshot")
3581
+ m.add_argument("--thread", default=None)
3582
+ # `open` cancels a covering decision and returns the source to ordinary actionable state.
3583
+ m.add_argument("--disposition", choices=[DISPOSITION_NEEDS_HUMAN, DISPOSITION_DISPATCHED, DISPOSITION_OPEN], default=DISPOSITION_DISPATCHED)
3584
+ m.add_argument("--check", default=None)
3585
+ m.add_argument("--comment", default=None)
3586
+ m.add_argument("--fetch-file", default=None) # reuse the tick's fetch for the at-mark baseline
3587
+ m.add_argument("--acted-edit-id", default=None) # snapshot-time identity for a covered comment
3588
+ m.add_argument("--residual-file", default=None)
3589
+ m.add_argument("--currency-key", default=None,
3590
+ help="exact branch-currency observation key emitted by snapshot")
3591
+ m.add_argument("--currency-disposition",
3592
+ choices=[DISPOSITION_OPEN, CURRENCY_CLAIMED, CURRENCY_CONFIRMED],
3593
+ default=None)
3594
+ m.add_argument("--semantic-conflict-fingerprint", default=None,
3595
+ help="semantic conflict identity preserved with a currency decision")
3596
+ m.add_argument("--currency-outcome",
3597
+ choices=[CURRENCY_OUTCOME_MUTATION_OBSERVED,
3598
+ CURRENCY_OUTCOME_PROVEN_NO_MUTATION,
3599
+ CURRENCY_OUTCOME_AMBIGUOUS], default=None)
3600
+ m.add_argument("--currency-inspected-fingerprint", default=None)
3601
+ m.add_argument("--answer-decision", default=None,
3602
+ help="exact current decision ID whose human answer is being recorded")
3603
+ m.add_argument("--answer-file", default=None,
3604
+ help="file containing the human answer to preserve until covered work moves")
3605
+ m.add_argument("--invariant-key", default=None,
3606
+ help="resolver-supplied review invariant; counted per unique head, never inferred")
3607
+ m.set_defaults(func=cmd_mark)
3608
+
3609
+ w = sub.add_parser("watch")
3610
+ w.add_argument("--pr", type=int, required=True)
3611
+ w.add_argument("--repo", default=None)
3612
+ w.add_argument("--state-dir", required=True)
3613
+ w.add_argument("--interval", type=float, default=150.0, help="poll cadence seconds")
3614
+ w.add_argument("--downstack-pr", type=int, action="append", default=[],
3615
+ help="lower managed-stack layer to keep probing; a new thread/comment/failing check/head there wakes downstack-actionable")
3616
+ w.add_argument("--downstack-fetch-file", action="append", default=[], help="N=path fixture for --downstack-pr N (tests)")
3617
+ w.add_argument("--settle-seconds", type=float, default=DEFAULT_SETTLE_SECONDS,
3618
+ help="quiet window before a merge-ready wake")
3619
+ w.add_argument("--feedback-coalesce-seconds", type=float, default=FEEDBACK_COALESCE_SECONDS,
3620
+ help="quiet window before a comment-only wake, so a burst costs one dispatch")
3621
+ w.add_argument("--blocked-external-drain-seconds", type=float, default=None,
3622
+ help="head-scoped review-quiet bound before a gated-CI handback")
3623
+ w.add_argument("--stop-file", default=None, help="path whose existence stops the watch")
3624
+ w.add_argument("--fetch-file", default=None)
3625
+ w.add_argument("--invocation-id", required=True,
3626
+ help="token emitted by the invocation's first snapshot")
3627
+ w.add_argument("--session-started-at", type=_session_started_at, required=True,
3628
+ help="fixed anchor emitted by the first snapshot")
3629
+ w.add_argument("--invocation-budget-seconds", type=float, required=True,
3630
+ help="fixed budget emitted by the invocation's first snapshot")
3631
+ w.set_defaults(func=cmd_watch)
3632
+
3633
+ args = p.parse_args()
3634
+ if (args.cmd in ("snapshot", "watch")
3635
+ and args.invocation_budget_seconds is not None
3636
+ and args.invocation_budget_seconds <= 0):
3637
+ p.error("--invocation-budget-seconds must be positive")
3638
+ if (args.cmd == "watch" and args.blocked_external_drain_seconds is not None
3639
+ and args.blocked_external_drain_seconds <= 0):
3640
+ p.error("--blocked-external-drain-seconds must be positive")
3641
+ if args.cmd == "snapshot":
3642
+ starting = args.start_invocation or args.reset_session
3643
+ continuing = args.continue_invocation
3644
+ if starting and (args.invocation_id or args.session_started_at):
3645
+ p.error("the first snapshot cannot combine --start-invocation with resume fields")
3646
+ if continuing and not (args.invocation_id and args.session_started_at
3647
+ and args.invocation_budget_seconds):
3648
+ p.error("--continue-invocation requires --invocation-id, --session-started-at, and --invocation-budget-seconds")
3649
+ if not starting and not continuing and not (args.invocation_id and args.session_started_at
3650
+ and args.invocation_budget_seconds):
3651
+ p.error("snapshot requires --start-invocation or --invocation-id")
3652
+ args.func(args)
3653
+
3654
+
3655
+ if __name__ == "__main__":
3656
+ main()