coding-os 0.3.2__py3-none-any.whl

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 (1304) hide show
  1. adapters/claude/README.md +6 -0
  2. adapters/claude/_install_helpers/extract_stacks.py +43 -0
  3. adapters/claude/_install_helpers/update_mcp_json.py +75 -0
  4. adapters/claude/adapter.yaml +164 -0
  5. adapters/claude/hooks/README.md +40 -0
  6. adapters/claude/hooks/agent_memory_sync.py +131 -0
  7. adapters/claude/hooks/ensure-agent-memory-link.sh +36 -0
  8. adapters/claude/hooks/sync-agent-memory.sh +21 -0
  9. adapters/claude/install.sh +86 -0
  10. adapters/claude/sdk_dispatcher.py +871 -0
  11. adapters/claude/settings.local.template.json +31 -0
  12. adapters/claude/settings.template.json +808 -0
  13. adapters/claude/update_mcp_json.py +85 -0
  14. adapters/codex/adapter.yaml +254 -0
  15. adapters/codex/chat_provider.py +230 -0
  16. adapters/codex/commands/formula-f1.md +129 -0
  17. adapters/codex/commands/formula-f10.md +100 -0
  18. adapters/codex/commands/formula-f11.md +123 -0
  19. adapters/codex/commands/formula-f2.md +139 -0
  20. adapters/codex/commands/formula-f3.md +127 -0
  21. adapters/codex/commands/formula-f4.md +101 -0
  22. adapters/codex/commands/formula-f5.md +135 -0
  23. adapters/codex/commands/formula-f6.md +147 -0
  24. adapters/codex/commands/formula-f7.md +111 -0
  25. adapters/codex/commands/formula-f8.md +133 -0
  26. adapters/codex/commands/formula-f9.md +112 -0
  27. adapters/codex/enable_codex_hooks.py +94 -0
  28. adapters/codex/ensure_codex_mcp.py +124 -0
  29. adapters/codex/hooks/codex-merge-hook-output.py +72 -0
  30. adapters/codex/hooks/codex-normalize-edit.py +96 -0
  31. adapters/codex/hooks/codex-postedit-dispatch.sh +75 -0
  32. adapters/codex/hooks/codex-posttool-dispatch.sh +70 -0
  33. adapters/codex/hooks/codex-preedit-dispatch.sh +83 -0
  34. adapters/codex/hooks/codex-pretool-dispatch.sh +82 -0
  35. adapters/codex/hooks/codex-sessionend-dispatch.sh +20 -0
  36. adapters/codex/hooks/codex-sessionstart-dispatch.sh +68 -0
  37. adapters/codex/hooks/codex-stop-dispatch.sh +73 -0
  38. adapters/codex/hooks/codex-userpromptsubmit-dispatch.sh +74 -0
  39. adapters/codex/hooks.template.json +208 -0
  40. adapters/codex/install.sh +83 -0
  41. adapters/codex/sdk_dispatcher.py +449 -0
  42. board_os/__init__.py +39 -0
  43. board_os/_agent_runtime.py +256 -0
  44. board_os/config.py +421 -0
  45. board_os/git_coherence.py +107 -0
  46. board_os/hub_adapter_manifest.py +140 -0
  47. board_os/mcp_tools.py +3228 -0
  48. board_os/migration.py +166 -0
  49. board_os/parser.py +317 -0
  50. board_os/presence.py +156 -0
  51. board_os/sync.py +320 -0
  52. board_os/transition_gates.py +224 -0
  53. board_os/transition_gates_cli.py +272 -0
  54. board_os/transition_gates_validator.py +551 -0
  55. board_os/verify_suites.py +126 -0
  56. board_os/verify_suites_cli.py +328 -0
  57. board_os/workflow.py +967 -0
  58. cli/__init__.py +0 -0
  59. cli/_data_types.py +248 -0
  60. cli/_init_helpers.py +587 -0
  61. cli/_resources.py +100 -0
  62. cli/adapter_registry.py +239 -0
  63. cli/add_stack.py +315 -0
  64. cli/aggregator.py +438 -0
  65. cli/board_commands.py +1218 -0
  66. cli/brain_commands.py +255 -0
  67. cli/cognition.py +345 -0
  68. cli/config_composer.py +349 -0
  69. cli/core_version.py +41 -0
  70. cli/cron_commands.py +278 -0
  71. cli/db_reset.py +298 -0
  72. cli/doc_commands.py +111 -0
  73. cli/doctor.py +2953 -0
  74. cli/doctor_board.py +365 -0
  75. cli/doctor_extras.py +1121 -0
  76. cli/doctor_graph.py +608 -0
  77. cli/doctor_tokens.py +254 -0
  78. cli/graph_commands.py +1265 -0
  79. cli/hook_renderer.py +393 -0
  80. cli/hub_commands.py +580 -0
  81. cli/list_adapters.py +79 -0
  82. cli/list_stacks.py +105 -0
  83. cli/logs_commands.py +89 -0
  84. cli/main.py +3070 -0
  85. cli/materialize_file.py +65 -0
  86. cli/mcp_start.py +153 -0
  87. cli/module_commands.py +513 -0
  88. cli/pr_commands.py +2024 -0
  89. cli/preset_commands.py +126 -0
  90. cli/preset_registry.py +171 -0
  91. cli/project_overrides.py +119 -0
  92. cli/registry.py +365 -0
  93. cli/remove_stack.py +492 -0
  94. cli/renderer.py +620 -0
  95. cli/setup.py +464 -0
  96. cli/skill_commands.py +689 -0
  97. cli/skill_registry.py +235 -0
  98. cli/skills_list.py +332 -0
  99. cli/stack_lint.py +351 -0
  100. cli/stack_registry.py +688 -0
  101. cli/subsystems.py +335 -0
  102. cli/sync_all.py +310 -0
  103. cli/tail_command.py +410 -0
  104. cli/update.py +608 -0
  105. cli/verify_since_edit.py +439 -0
  106. coding_os-0.3.2.dist-info/METADATA +508 -0
  107. coding_os-0.3.2.dist-info/RECORD +1304 -0
  108. coding_os-0.3.2.dist-info/WHEEL +5 -0
  109. coding_os-0.3.2.dist-info/entry_points.txt +5 -0
  110. coding_os-0.3.2.dist-info/licenses/LICENSE +201 -0
  111. coding_os-0.3.2.dist-info/top_level.txt +10 -0
  112. core/__init__.py +0 -0
  113. core/board_os/__init__.py +39 -0
  114. core/board_os/_agent_runtime.py +256 -0
  115. core/board_os/config.py +421 -0
  116. core/board_os/git_coherence.py +107 -0
  117. core/board_os/hub_adapter_manifest.py +140 -0
  118. core/board_os/mcp_tools.py +3228 -0
  119. core/board_os/migration.py +166 -0
  120. core/board_os/parser.py +317 -0
  121. core/board_os/presence.py +156 -0
  122. core/board_os/sync.py +320 -0
  123. core/board_os/transition-gates.yaml +176 -0
  124. core/board_os/transition_gates.py +224 -0
  125. core/board_os/transition_gates_cli.py +272 -0
  126. core/board_os/transition_gates_validator.py +551 -0
  127. core/board_os/verify-suites.yaml +113 -0
  128. core/board_os/verify_suites.py +126 -0
  129. core/board_os/verify_suites_cli.py +328 -0
  130. core/board_os/workflow.py +967 -0
  131. core/commands/board.md +27 -0
  132. core/commands/classify.md +23 -0
  133. core/commands/compose.md +23 -0
  134. core/commands/daily.md +31 -0
  135. core/commands/diagnose.md +7 -0
  136. core/commands/memory-search.md +23 -0
  137. core/commands/new-project.md +33 -0
  138. core/commands/retro.md +38 -0
  139. core/commands/review.md +14 -0
  140. core/commands/task.md +17 -0
  141. core/commands/verify.md +34 -0
  142. core/docs/thinking_os-final-edition.md +1449 -0
  143. core/doctor-config.yaml +74 -0
  144. core/graph_os/__init__.py +29 -0
  145. core/graph_os/backend.py +233 -0
  146. core/graph_os/backends/__init__.py +13 -0
  147. core/graph_os/backends/sqlite_backend.py +1053 -0
  148. core/graph_os/bench/__init__.py +17 -0
  149. core/graph_os/bench/fixtures.py +56 -0
  150. core/graph_os/bench/harness.py +95 -0
  151. core/graph_os/bench/persian_precision.py +142 -0
  152. core/graph_os/bench/scale_500k.py +120 -0
  153. core/graph_os/bench/token_cost.py +170 -0
  154. core/graph_os/bench/viewer_fps.py +110 -0
  155. core/graph_os/communities.py +410 -0
  156. core/graph_os/enterprise.py +218 -0
  157. core/graph_os/entry_points.py +226 -0
  158. core/graph_os/extractors/__init__.py +25 -0
  159. core/graph_os/extractors/code_generic.py +914 -0
  160. core/graph_os/extractors/code_go.py +1422 -0
  161. core/graph_os/extractors/code_json.py +340 -0
  162. core/graph_os/extractors/code_php.py +979 -0
  163. core/graph_os/extractors/code_python.py +1454 -0
  164. core/graph_os/extractors/code_shell.py +538 -0
  165. core/graph_os/extractors/code_toml.py +302 -0
  166. core/graph_os/extractors/code_ts.py +1665 -0
  167. core/graph_os/extractors/code_yaml.py +394 -0
  168. core/graph_os/extractors/contracts.py +1592 -0
  169. core/graph_os/extractors/md_links.py +890 -0
  170. core/graph_os/extractors/task_deps.py +345 -0
  171. core/graph_os/groups/__init__.py +22 -0
  172. core/graph_os/groups/cross_repo.py +156 -0
  173. core/graph_os/groups/manifest.py +141 -0
  174. core/graph_os/ingest/__init__.py +19 -0
  175. core/graph_os/ingest/base.py +306 -0
  176. core/graph_os/ingest/github.py +112 -0
  177. core/graph_os/ingest/zip.py +95 -0
  178. core/graph_os/toolchain.py +393 -0
  179. core/graph_os/tools/__init__.py +9 -0
  180. core/graph_os/tools/graph.py +5573 -0
  181. core/graph_os/tools/reindex_dispatch.py +730 -0
  182. core/graph_os/tree_sitter_overlay.py +235 -0
  183. core/graph_os/types.py +252 -0
  184. core/graph_os/vec_index.py +277 -0
  185. core/graph_os/viewer/__init__.py +12 -0
  186. core/graph_os/viewer/exporter.py +93 -0
  187. core/graph_os/viewer/template.py +189 -0
  188. core/hooks/_helpers/_paths.py +40 -0
  189. core/hooks/_helpers/advance_role.py +72 -0
  190. core/hooks/_helpers/auto_compose.py +228 -0
  191. core/hooks/_helpers/auto_validate_lessons.py +55 -0
  192. core/hooks/_helpers/branch_guard_check.py +796 -0
  193. core/hooks/_helpers/check_commit_message.py +108 -0
  194. core/hooks/_helpers/check_dangerous_rm.py +80 -0
  195. core/hooks/_helpers/check_git_bypass.py +154 -0
  196. core/hooks/_helpers/check_git_destructive.py +77 -0
  197. core/hooks/_helpers/check_settings_write.py +97 -0
  198. core/hooks/_helpers/consume_override.py +51 -0
  199. core/hooks/_helpers/context_budget.py +77 -0
  200. core/hooks/_helpers/cos_say_json.py +103 -0
  201. core/hooks/_helpers/destructive_edit_check.py +163 -0
  202. core/hooks/_helpers/detect_status_transition.py +82 -0
  203. core/hooks/_helpers/digest_regen.py +56 -0
  204. core/hooks/_helpers/doc_sync_check.py +498 -0
  205. core/hooks/_helpers/drain_embedding_outbox.py +52 -0
  206. core/hooks/_helpers/extract_additional_context.py +51 -0
  207. core/hooks/_helpers/extract_commit_msg_arg.py +74 -0
  208. core/hooks/_helpers/git_command_parse.py +424 -0
  209. core/hooks/_helpers/git_settings_fields.py +47 -0
  210. core/hooks/_helpers/graph_context_match.py +37 -0
  211. core/hooks/_helpers/graph_marker_check.py +70 -0
  212. core/hooks/_helpers/jit_recall.py +56 -0
  213. core/hooks/_helpers/json_field.py +41 -0
  214. core/hooks/_helpers/narrative_signal.py +59 -0
  215. core/hooks/_helpers/observation_count.py +31 -0
  216. core/hooks/_helpers/pre_commit_batch.py +177 -0
  217. core/hooks/_helpers/pre_commit_fake_input.py +42 -0
  218. core/hooks/_helpers/presence_gc.py +102 -0
  219. core/hooks/_helpers/presence_write.py +167 -0
  220. core/hooks/_helpers/recover_indirect.py +35 -0
  221. core/hooks/_helpers/routing_evolution.py +104 -0
  222. core/hooks/_helpers/session_recap.py +72 -0
  223. core/hooks/_helpers/skill_primer.py +229 -0
  224. core/hooks/_helpers/task_sync.py +59 -0
  225. core/hooks/_helpers/tool_failure_capture.py +147 -0
  226. core/hooks/_helpers/trajectory_autosnap.py +278 -0
  227. core/hooks/_helpers/trajectory_startup.py +62 -0
  228. core/hooks/_helpers/turn_summary.py +82 -0
  229. core/hooks/_helpers/validate_task_frontmatter.py +98 -0
  230. core/hooks/_helpers/wip_limit_check.py +103 -0
  231. core/hooks/_helpers/wip_lines.py +53 -0
  232. core/hooks/_helpers/work_log_append.py +89 -0
  233. core/hooks/_helpers/wrap_dispatch_output.py +82 -0
  234. core/hooks/advance-role.sh +48 -0
  235. core/hooks/agent-presence.sh +179 -0
  236. core/hooks/auto-brain-decay.sh +184 -0
  237. core/hooks/auto-compose-roles.sh +83 -0
  238. core/hooks/auto-graph-reconcile-shell.sh +119 -0
  239. core/hooks/auto-regen-doc-index.sh +120 -0
  240. core/hooks/auto-reindex-docs.sh +130 -0
  241. core/hooks/auto-task-sync.sh +56 -0
  242. core/hooks/auto-trace-rotate.sh +88 -0
  243. core/hooks/block-bad-patterns.sh +212 -0
  244. core/hooks/block-dangerous-commands.sh +182 -0
  245. core/hooks/block-hardcoded-literals.sh +90 -0
  246. core/hooks/block-migration-conflict.sh +114 -0
  247. core/hooks/block-protected-files.sh +129 -0
  248. core/hooks/block-secrets.sh +185 -0
  249. core/hooks/block-shared-tree-edit.sh +75 -0
  250. core/hooks/block-uv-heredoc.sh +78 -0
  251. core/hooks/branch-guard.sh +122 -0
  252. core/hooks/capture-observation.sh +76 -0
  253. core/hooks/capture-tool-failure.sh +24 -0
  254. core/hooks/capture-work-log.sh +89 -0
  255. core/hooks/check-agents-md-refs.sh +75 -0
  256. core/hooks/check-agents-md-size.sh +49 -0
  257. core/hooks/check-capture-worked.sh +148 -0
  258. core/hooks/check-doc-size.sh +61 -0
  259. core/hooks/check-mcp-extras.sh +92 -0
  260. core/hooks/check-state.sh +87 -0
  261. core/hooks/classify-task-mode.sh +103 -0
  262. core/hooks/cos-env.sh +1301 -0
  263. core/hooks/drain-embedding-outbox.sh +24 -0
  264. core/hooks/enforce-anti-ambiguity.sh +74 -0
  265. core/hooks/enforce-commit-message.sh +73 -0
  266. core/hooks/enforce-doc-anchor.sh +224 -0
  267. core/hooks/enforce-doc-sync.sh +206 -0
  268. core/hooks/enforce-graph-context.sh +89 -0
  269. core/hooks/enforce-graph-first-read.sh +94 -0
  270. core/hooks/enforce-memory-check.sh +128 -0
  271. core/hooks/enforce-rename-plan.sh +78 -0
  272. core/hooks/enforce-scaffold-boundary.sh +68 -0
  273. core/hooks/enforce-skill.sh +125 -0
  274. core/hooks/enforce-task-body.sh +52 -0
  275. core/hooks/enforce-task-start.sh +81 -0
  276. core/hooks/enforce-task-transition.sh +75 -0
  277. core/hooks/enforce-template.sh +143 -0
  278. core/hooks/enforce-verify.sh +112 -0
  279. core/hooks/enforce-wip-limit.sh +43 -0
  280. core/hooks/enforce-zoom.sh +69 -0
  281. core/hooks/ensure-hub-up.sh +67 -0
  282. core/hooks/inject-mcp-caller-session.sh +70 -0
  283. core/hooks/jit-recall.sh +65 -0
  284. core/hooks/link-commit-to-task.sh +143 -0
  285. core/hooks/lint-task.sh +40 -0
  286. core/hooks/nudge-docs-first.sh +71 -0
  287. core/hooks/nudge-git-mode.sh +29 -0
  288. core/hooks/nudge-graph-os.sh +118 -0
  289. core/hooks/nudge-learn-narrative.sh +36 -0
  290. core/hooks/nudge-model-routing.sh +32 -0
  291. core/hooks/nudge-reentry.sh +101 -0
  292. core/hooks/nudge-reuse-first.sh +68 -0
  293. core/hooks/nudge-task-discovery.sh +81 -0
  294. core/hooks/nudge-thinking-os.sh +109 -0
  295. core/hooks/pr-reap.sh +23 -0
  296. core/hooks/reclaim-sweep.sh +58 -0
  297. core/hooks/record-verify-auto.sh +77 -0
  298. core/hooks/record-verify.sh +74 -0
  299. core/hooks/regen-reminder.sh +104 -0
  300. core/hooks/registry.yaml +1262 -0
  301. core/hooks/remind-daily.sh +27 -0
  302. core/hooks/remind-dogfood.sh +70 -0
  303. core/hooks/remind-learn-validate.sh +94 -0
  304. core/hooks/rules-primer.sh +50 -0
  305. core/hooks/search-enforce-inventory.sh +108 -0
  306. core/hooks/search-verify-remaining.sh +132 -0
  307. core/hooks/session-context.sh +729 -0
  308. core/hooks/session-end.sh +145 -0
  309. core/hooks/session-skill-primer.sh +43 -0
  310. core/hooks/snapshot-transcript.sh +56 -0
  311. core/hooks/sync-task-current.sh +85 -0
  312. core/hooks/test-first-reminder.sh +120 -0
  313. core/hooks/test-governor.sh +173 -0
  314. core/hooks/thinking_os-gate.sh +52 -0
  315. core/hooks/track-backtrack.sh +35 -0
  316. core/hooks/track-discovery.sh +121 -0
  317. core/hooks/track-skill.sh +53 -0
  318. core/hooks/validate-task-frontmatter.sh +49 -0
  319. core/hooks/verify-rename-callers.sh +119 -0
  320. core/hooks/warn-abandoned-task.sh +99 -0
  321. core/hooks/warn-destructive-edit.sh +64 -0
  322. core/hooks/warn-diff-size.sh +43 -0
  323. core/hooks/warn-graph-empty.sh +81 -0
  324. core/hooks/warn-mcp-down.sh +190 -0
  325. core/hooks/write-state.sh +55 -0
  326. core/logging_os/__init__.py +33 -0
  327. core/logging_os/api.py +127 -0
  328. core/logging_os/bridge.py +80 -0
  329. core/logging_os/config.py +172 -0
  330. core/logging_os/fingerprint.py +25 -0
  331. core/logging_os/redact.py +53 -0
  332. core/logging_os/render.py +83 -0
  333. core/logging_os/sinks.py +164 -0
  334. core/rules/anti-overengineering.md +44 -0
  335. core/rules/api-contract-discipline.md +41 -0
  336. core/rules/dimension-registry.md +155 -0
  337. core/rules/git-workflow.md +57 -0
  338. core/rules/memory.md +46 -0
  339. core/rules/model-routing.md +22 -0
  340. core/rules/skill-enforcement.md +75 -0
  341. core/rules/test-discipline.md +38 -0
  342. core/rules/thinking_os.md +48 -0
  343. core/rules/transparency-banner.md +37 -0
  344. core/runtime_paths.yaml +36 -0
  345. core/scaffold_manifest.json +14430 -0
  346. core/scheduled/__init__.py +0 -0
  347. core/scheduled/_activity.py +126 -0
  348. core/scheduled/_state.py +113 -0
  349. core/scheduled/config.py +86 -0
  350. core/scheduled/dep_reconcile.py +135 -0
  351. core/scheduled/error_sweep.py +137 -0
  352. core/scheduled/nightly.py +930 -0
  353. core/scheduled/responsive_extract.py +65 -0
  354. core/schemas/adapter.schema.json +269 -0
  355. core/schemas/preset.schema.json +50 -0
  356. core/schemas/skill.schema.json +81 -0
  357. core/schemas/stack.schema.json +404 -0
  358. core/scripts/_lib.sh +9 -0
  359. core/scripts/docs-lint.sh +228 -0
  360. core/scripts/docs-nav-fix.sh +133 -0
  361. core/scripts/docs-staleness-check.sh +154 -0
  362. core/scripts/install-adapter.sh +266 -0
  363. core/scripts/link-stack-skills.sh +52 -0
  364. core/scripts/log-latest.sh +106 -0
  365. core/scripts/log-search.sh +89 -0
  366. core/scripts/log-write.sh +134 -0
  367. core/scripts/ref-resolve.sh +71 -0
  368. core/skills/a11y/SKILL.md +305 -0
  369. core/skills/a11y/assets/a11y-checklist.md +137 -0
  370. core/skills/a11y/references/aria-and-focus.md +247 -0
  371. core/skills/a11y/references/rn-accessibility.md +343 -0
  372. core/skills/a11y/references/screen-reader-testing.md +190 -0
  373. core/skills/agent-memory/SKILL.md +191 -0
  374. core/skills/agent-memory/assets/memory-checklist.md +21 -0
  375. core/skills/agent-memory/references/memory-recipes.md +57 -0
  376. core/skills/api-design/SKILL.md +232 -0
  377. core/skills/api-design/assets/api-design-checklist.md +110 -0
  378. core/skills/api-design/references/error-envelope.md +381 -0
  379. core/skills/api-design/references/idempotency-pagination.md +312 -0
  380. core/skills/api-design/references/rest-contracts.md +426 -0
  381. core/skills/auth-patterns/SKILL.md +352 -0
  382. core/skills/auth-patterns/assets/auth-checklist.md +118 -0
  383. core/skills/auth-patterns/references/jwt-and-service-tokens.md +343 -0
  384. core/skills/auth-patterns/references/passkeys-2fa.md +289 -0
  385. core/skills/auth-patterns/references/sessions-vs-jwt.md +230 -0
  386. core/skills/auth-patterns/scripts/cookie-flag-check.py +146 -0
  387. core/skills/backend-fundamentals/SKILL.md +238 -0
  388. core/skills/backend-fundamentals/assets/backend-checklist.md +27 -0
  389. core/skills/backend-fundamentals/references/backend-patterns.md +56 -0
  390. core/skills/backend-fundamentals/scripts/check_layering.py +83 -0
  391. core/skills/clean-code/SKILL.md +642 -0
  392. core/skills/clean-code/scripts/audit-fail-closed.py +167 -0
  393. core/skills/codebase-explorer/SKILL.md +89 -0
  394. core/skills/codebase-explorer/assets/reading-checklist.md +24 -0
  395. core/skills/codebase-explorer/references/reading-strategies.md +52 -0
  396. core/skills/codebase-explorer/scripts/outline.py +99 -0
  397. core/skills/db-design/SKILL.md +327 -0
  398. core/skills/db-design/assets/migration-template.sql +49 -0
  399. core/skills/db-design/references/migration-discipline.md +290 -0
  400. core/skills/db-design/references/postgres-patterns.md +340 -0
  401. core/skills/db-design/scripts/migration-safety.sh +150 -0
  402. core/skills/deployment-cicd/SKILL.md +260 -0
  403. core/skills/deployment-cicd/assets/deploy-checklist.md +26 -0
  404. core/skills/deployment-cicd/references/pipeline-and-release.md +54 -0
  405. core/skills/deployment-cicd/scripts/lint_workflow.py +79 -0
  406. core/skills/docker/SKILL.md +114 -0
  407. core/skills/docker/assets/dockerfile-checklist.md +31 -0
  408. core/skills/docker/references/compose-patterns.md +66 -0
  409. core/skills/docker/references/dockerfile-optimization.md +64 -0
  410. core/skills/docker/scripts/lint_dockerfile.sh +48 -0
  411. core/skills/docker/versions.json +16 -0
  412. core/skills/end-to-end-testing/SKILL.md +101 -0
  413. core/skills/end-to-end-testing/assets/e2e-checklist.md +23 -0
  414. core/skills/end-to-end-testing/references/maestro.md +63 -0
  415. core/skills/end-to-end-testing/references/playwright.md +68 -0
  416. core/skills/end-to-end-testing/scripts/lint_e2e.py +88 -0
  417. core/skills/end-to-end-testing/versions.json +16 -0
  418. core/skills/frontend-design/SKILL.md +76 -0
  419. core/skills/frontend-design/assets/design-checklist.md +29 -0
  420. core/skills/frontend-design/references/design-principles.md +65 -0
  421. core/skills/frontend-design/scripts/check_contrast.py +89 -0
  422. core/skills/frontend-fundamentals/SKILL.md +213 -0
  423. core/skills/frontend-fundamentals/assets/frontend-checklist.md +25 -0
  424. core/skills/frontend-fundamentals/references/rendering-and-state.md +66 -0
  425. core/skills/frontend-fundamentals/scripts/check_frontend.py +86 -0
  426. core/skills/graph-explorer/SKILL.md +215 -0
  427. core/skills/graph-explorer/scripts/explain-impact.sh +64 -0
  428. core/skills/graphql/SKILL.md +187 -0
  429. core/skills/grpc-microservices/SKILL.md +174 -0
  430. core/skills/hexagonal-architecture/SKILL.md +199 -0
  431. core/skills/hexagonal-architecture/assets/folder-scaffold.md +233 -0
  432. core/skills/hexagonal-architecture/references/anti-patterns.md +129 -0
  433. core/skills/hexagonal-architecture/references/go-fiber-layout.md +429 -0
  434. core/skills/hexagonal-architecture/references/python-fastapi-layout.md +453 -0
  435. core/skills/hexagonal-architecture/references/react-native-layout.md +428 -0
  436. core/skills/i18n/SKILL.md +126 -0
  437. core/skills/incident-response/SKILL.md +225 -0
  438. core/skills/incident-response/assets/incident-checklist.md +29 -0
  439. core/skills/incident-response/references/severity-and-runbook.md +53 -0
  440. core/skills/incident-response/scripts/classify_severity.py +86 -0
  441. core/skills/linux-sysadmin/SKILL.md +115 -0
  442. core/skills/linux-sysadmin/assets/hardening-checklist.md +29 -0
  443. core/skills/linux-sysadmin/references/ssh-hardening.md +62 -0
  444. core/skills/linux-sysadmin/references/systemd-and-services.md +77 -0
  445. core/skills/linux-sysadmin/scripts/triage.sh +50 -0
  446. core/skills/linux-sysadmin/versions.json +17 -0
  447. core/skills/llm-patterns/SKILL.md +410 -0
  448. core/skills/llm-patterns/assets/llm-feature-checklist.md +26 -0
  449. core/skills/llm-patterns/references/rag-and-evals.md +59 -0
  450. core/skills/llm-patterns/scripts/estimate_tokens.py +75 -0
  451. core/skills/messaging-queues/SKILL.md +142 -0
  452. core/skills/mobile-fundamentals/SKILL.md +406 -0
  453. core/skills/mobile-fundamentals/assets/mobile-launch-checklist.md +130 -0
  454. core/skills/mobile-fundamentals/references/navigation-and-deep-links.md +337 -0
  455. core/skills/mobile-fundamentals/references/offline-sync.md +339 -0
  456. core/skills/node-backend/SKILL.md +114 -0
  457. core/skills/node-backend/assets/node-checklist.md +25 -0
  458. core/skills/node-backend/references/async-and-errors.md +67 -0
  459. core/skills/node-backend/references/event-loop.md +65 -0
  460. core/skills/node-backend/scripts/check_package.py +78 -0
  461. core/skills/node-backend/versions.json +17 -0
  462. core/skills/observability/SKILL.md +289 -0
  463. core/skills/observability/assets/observability-checklist.md +27 -0
  464. core/skills/observability/references/instrumentation.md +57 -0
  465. core/skills/observability/scripts/lint_logging.py +77 -0
  466. core/skills/payments/SKILL.md +102 -0
  467. core/skills/performance/SKILL.md +305 -0
  468. core/skills/performance/assets/perf-checklist.md +143 -0
  469. core/skills/performance/references/mobile-performance.md +249 -0
  470. core/skills/performance/references/web-vitals.md +209 -0
  471. core/skills/php/SKILL.md +116 -0
  472. core/skills/php/assets/php-checklist.md +26 -0
  473. core/skills/php/references/modern-php.md +64 -0
  474. core/skills/php/references/security.md +72 -0
  475. core/skills/php/scripts/scan_php_smells.py +99 -0
  476. core/skills/php/versions.json +9 -0
  477. core/skills/pr-mode-driver/SKILL.md +65 -0
  478. core/skills/realtime-websockets/SKILL.md +152 -0
  479. core/skills/redis/SKILL.md +105 -0
  480. core/skills/redis/assets/redis-checklist.md +27 -0
  481. core/skills/redis/references/operations.md +66 -0
  482. core/skills/redis/references/patterns.md +62 -0
  483. core/skills/redis/scripts/analyze_info.py +101 -0
  484. core/skills/redis/versions.json +9 -0
  485. core/skills/search/SKILL.md +91 -0
  486. core/skills/search/references/grep.md +76 -0
  487. core/skills/search/scripts/verify-count.sh +73 -0
  488. core/skills/search-infra/SKILL.md +109 -0
  489. core/skills/security-mobile/SKILL.md +394 -0
  490. core/skills/security-mobile/assets/mobile-security-checklist.md +117 -0
  491. core/skills/security-mobile/references/masvs-l1-checklist.md +127 -0
  492. core/skills/security-web/SKILL.md +217 -0
  493. core/skills/security-web/assets/security-web-checklist.md +167 -0
  494. core/skills/security-web/references/owasp-top-10.md +551 -0
  495. core/skills/security-web/references/supply-chain.md +179 -0
  496. core/skills/security-web/scripts/csp-check.sh +153 -0
  497. core/skills/shell-scripting/SKILL.md +128 -0
  498. core/skills/shell-scripting/assets/script-checklist.md +33 -0
  499. core/skills/shell-scripting/references/argument-parsing.md +84 -0
  500. core/skills/shell-scripting/references/bash-robustness.md +78 -0
  501. core/skills/shell-scripting/scripts/lint_script.sh +53 -0
  502. core/skills/shell-scripting/scripts/new_script.py +131 -0
  503. core/skills/shell-scripting/versions.json +23 -0
  504. core/skills/sql-authoring/SKILL.md +104 -0
  505. core/skills/sql-authoring/assets/query-review-checklist.md +26 -0
  506. core/skills/sql-authoring/references/query-patterns.md +89 -0
  507. core/skills/sql-authoring/references/reading-explain.md +52 -0
  508. core/skills/sql-authoring/scripts/analyze_plan.py +100 -0
  509. core/skills/sql-authoring/versions.json +17 -0
  510. core/skills/state-management/SKILL.md +428 -0
  511. core/skills/state-management/references/tanstack-query-recipes.md +301 -0
  512. core/skills/state-management/references/zustand-recipes.md +364 -0
  513. core/skills/supabase/SKILL.md +108 -0
  514. core/skills/supabase/assets/supabase-checklist.md +26 -0
  515. core/skills/supabase/references/realtime-and-storage.md +55 -0
  516. core/skills/supabase/references/rls-and-auth.md +65 -0
  517. core/skills/supabase/scripts/check_rls.py +88 -0
  518. core/skills/supabase/versions.json +9 -0
  519. core/skills/task-driver/SKILL.md +272 -0
  520. core/skills/task-driver/scripts/task-lint.sh +163 -0
  521. core/skills/technical-writing/SKILL.md +85 -0
  522. core/skills/technical-writing/assets/doc-checklist.md +29 -0
  523. core/skills/technical-writing/references/doc-anatomy.md +53 -0
  524. core/skills/technical-writing/references/writing-craft.md +59 -0
  525. core/skills/technical-writing/scripts/new_doc.py +81 -0
  526. core/skills/terraform-k8s/SKILL.md +133 -0
  527. core/skills/testing-strategy/SKILL.md +266 -0
  528. core/skills/testing-strategy/assets/test-review-checklist.md +25 -0
  529. core/skills/testing-strategy/references/test-types.md +52 -0
  530. core/skills/testing-strategy/scripts/coverage_gate.py +79 -0
  531. core/skills/thinking_os/SKILL.md +288 -0
  532. core/skills/thinking_os/scripts/classify.sh +122 -0
  533. core/skills/typescript/SKILL.md +110 -0
  534. core/skills/typescript/assets/typescript-checklist.md +24 -0
  535. core/skills/typescript/references/strictness.md +53 -0
  536. core/skills/typescript/references/type-system.md +86 -0
  537. core/skills/typescript/scripts/check_tsconfig.py +92 -0
  538. core/skills/typescript/versions.json +9 -0
  539. core/subsystems.yaml +202 -0
  540. core/thinking_os/__init__.py +1 -0
  541. core/thinking_os/_agent_markers.py +32 -0
  542. core/thinking_os/agents/README.md +71 -0
  543. core/thinking_os/agents/analyst.md +139 -0
  544. core/thinking_os/agents/architect.md +127 -0
  545. core/thinking_os/agents/debugger.md +111 -0
  546. core/thinking_os/agents/deployer.md +112 -0
  547. core/thinking_os/agents/distiller.md +28 -0
  548. core/thinking_os/agents/documenter.md +101 -0
  549. core/thinking_os/agents/implementer.md +135 -0
  550. core/thinking_os/agents/internal/session_observer.md +39 -0
  551. core/thinking_os/agents/observer.md +100 -0
  552. core/thinking_os/agents/onboarder.md +81 -0
  553. core/thinking_os/agents/refactorer.md +123 -0
  554. core/thinking_os/agents/repairer.md +47 -0
  555. core/thinking_os/agents/researcher.md +129 -0
  556. core/thinking_os/agents/reviewer.md +147 -0
  557. core/thinking_os/agents/security_auditor.md +133 -0
  558. core/thinking_os/background.py +405 -0
  559. core/thinking_os/bootstrap_outcomes.py +200 -0
  560. core/thinking_os/budget.py +302 -0
  561. core/thinking_os/capture.py +495 -0
  562. core/thinking_os/cognition.py +516 -0
  563. core/thinking_os/cognition_schemas.py +517 -0
  564. core/thinking_os/compress.py +192 -0
  565. core/thinking_os/concepts.py +233 -0
  566. core/thinking_os/dashboard.py +159 -0
  567. core/thinking_os/database.py +2883 -0
  568. core/thinking_os/decay.py +393 -0
  569. core/thinking_os/digest.py +295 -0
  570. core/thinking_os/dispatcher.py +192 -0
  571. core/thinking_os/dispatcher_helpers.py +48 -0
  572. core/thinking_os/dispatchers/__init__.py +3 -0
  573. core/thinking_os/dispatchers/default.py +47 -0
  574. core/thinking_os/distill.py +192 -0
  575. core/thinking_os/doc_indexer.py +905 -0
  576. core/thinking_os/embeddings.py +943 -0
  577. core/thinking_os/formula_composer.py +556 -0
  578. core/thinking_os/gate_marker.py +75 -0
  579. core/thinking_os/graph.py +296 -0
  580. core/thinking_os/graph_indexer.py +360 -0
  581. core/thinking_os/health_check.py +517 -0
  582. core/thinking_os/impact.py +119 -0
  583. core/thinking_os/memory_gc.py +356 -0
  584. core/thinking_os/migrator_embeddings.py +318 -0
  585. core/thinking_os/precision.py +194 -0
  586. core/thinking_os/presets/registry.yaml +127 -0
  587. core/thinking_os/record_outcome.py +399 -0
  588. core/thinking_os/repair.py +105 -0
  589. core/thinking_os/retrieval_quality.py +239 -0
  590. core/thinking_os/roles/analyst.yaml +83 -0
  591. core/thinking_os/roles/architect.yaml +88 -0
  592. core/thinking_os/roles/debugger.yaml +71 -0
  593. core/thinking_os/roles/deployer.yaml +71 -0
  594. core/thinking_os/roles/documenter.yaml +72 -0
  595. core/thinking_os/roles/implementer.yaml +83 -0
  596. core/thinking_os/roles/observer.yaml +70 -0
  597. core/thinking_os/roles/refactorer.yaml +71 -0
  598. core/thinking_os/roles/researcher.yaml +72 -0
  599. core/thinking_os/roles/reviewer.yaml +79 -0
  600. core/thinking_os/roles/security_auditor.yaml +83 -0
  601. core/thinking_os/roles_state.py +168 -0
  602. core/thinking_os/sanitizer.py +320 -0
  603. core/thinking_os/server.py +3160 -0
  604. core/thinking_os/session_enrich.py +272 -0
  605. core/thinking_os/session_observe_worker.py +111 -0
  606. core/thinking_os/session_startup.py +74 -0
  607. core/thinking_os/session_summary.py +245 -0
  608. core/thinking_os/situations/registry.yaml +99 -0
  609. core/thinking_os/task_analyzer.py +462 -0
  610. core/thinking_os/task_parser.py +342 -0
  611. core/thinking_os/task_sync.py +73 -0
  612. core/thinking_os/tools/__init__.py +6 -0
  613. core/thinking_os/tools/_shared.py +947 -0
  614. core/thinking_os/tools/cognition.py +1867 -0
  615. core/thinking_os/tools/docs.py +770 -0
  616. core/thinking_os/tools/learning.py +2078 -0
  617. core/thinking_os/tools/logs.py +79 -0
  618. core/thinking_os/tools/memory.py +840 -0
  619. core/thinking_os/tools/metrics.py +200 -0
  620. core/thinking_os/tools/retrieve.py +415 -0
  621. core/thinking_os/tools/routing.py +658 -0
  622. core/thinking_os/tools/tasks.py +449 -0
  623. core/thinking_os/tools/trajectory.py +181 -0
  624. core/thinking_os/tracing.py +235 -0
  625. core/web/__init__.py +5 -0
  626. core/web/_cache.py +118 -0
  627. core/web/_deps.py +56 -0
  628. core/web/_envelope.py +85 -0
  629. core/web/_project_context.py +140 -0
  630. core/web/chat_providers.py +108 -0
  631. core/web/init_jobs.py +216 -0
  632. core/web/routes/__init__.py +25 -0
  633. core/web/routes/_bounded_read.py +76 -0
  634. core/web/routes/board.py +1089 -0
  635. core/web/routes/cognition.py +1838 -0
  636. core/web/routes/config.py +635 -0
  637. core/web/routes/graph.py +513 -0
  638. core/web/routes/health.py +180 -0
  639. core/web/routes/hooks.py +288 -0
  640. core/web/routes/hub.py +1219 -0
  641. core/web/routes/logs.py +374 -0
  642. core/web/routes/metrics.py +43 -0
  643. core/web/routes/observability.py +400 -0
  644. core/web/routes/patterns.py +227 -0
  645. core/web/routes/presence.py +609 -0
  646. core/web/routes/roles.py +446 -0
  647. core/web/routes/scheduled.py +261 -0
  648. core/web/routes/search.py +238 -0
  649. core/web/routes/sessions.py +220 -0
  650. core/web/routes/settings.py +363 -0
  651. core/web/routes/stream.py +547 -0
  652. core/web/security.py +157 -0
  653. core/web/server.py +283 -0
  654. graph_os/__init__.py +29 -0
  655. graph_os/backend.py +233 -0
  656. graph_os/backends/__init__.py +13 -0
  657. graph_os/backends/sqlite_backend.py +1053 -0
  658. graph_os/communities.py +410 -0
  659. graph_os/enterprise.py +218 -0
  660. graph_os/entry_points.py +226 -0
  661. graph_os/extractors/__init__.py +25 -0
  662. graph_os/extractors/code_generic.py +914 -0
  663. graph_os/extractors/code_go.py +1422 -0
  664. graph_os/extractors/code_json.py +340 -0
  665. graph_os/extractors/code_php.py +979 -0
  666. graph_os/extractors/code_python.py +1454 -0
  667. graph_os/extractors/code_shell.py +538 -0
  668. graph_os/extractors/code_toml.py +302 -0
  669. graph_os/extractors/code_ts.py +1665 -0
  670. graph_os/extractors/code_yaml.py +394 -0
  671. graph_os/extractors/contracts.py +1592 -0
  672. graph_os/extractors/md_links.py +890 -0
  673. graph_os/extractors/task_deps.py +345 -0
  674. graph_os/groups/__init__.py +22 -0
  675. graph_os/groups/cross_repo.py +156 -0
  676. graph_os/groups/manifest.py +141 -0
  677. graph_os/ingest/__init__.py +19 -0
  678. graph_os/ingest/base.py +306 -0
  679. graph_os/ingest/github.py +112 -0
  680. graph_os/ingest/zip.py +95 -0
  681. graph_os/toolchain.py +393 -0
  682. graph_os/tools/__init__.py +9 -0
  683. graph_os/tools/graph.py +5573 -0
  684. graph_os/tools/reindex_dispatch.py +730 -0
  685. graph_os/tree_sitter_overlay.py +235 -0
  686. graph_os/types.py +252 -0
  687. graph_os/vec_index.py +277 -0
  688. graph_os/viewer/__init__.py +12 -0
  689. graph_os/viewer/exporter.py +93 -0
  690. graph_os/viewer/template.py +189 -0
  691. scheduled/__init__.py +0 -0
  692. scheduled/_activity.py +126 -0
  693. scheduled/_state.py +113 -0
  694. scheduled/config.py +86 -0
  695. scheduled/dep_reconcile.py +135 -0
  696. scheduled/error_sweep.py +137 -0
  697. scheduled/nightly.py +930 -0
  698. scheduled/responsive_extract.py +65 -0
  699. scripts/__init__.py +4 -0
  700. scripts/_commit_msg_body.sh +31 -0
  701. scripts/_post_commit_body.sh +49 -0
  702. scripts/_pre_commit_body.sh +121 -0
  703. scripts/_prepare_commit_msg_body.sh +53 -0
  704. scripts/audit_mcp_tools.py +693 -0
  705. scripts/bench_sdk_dispatcher.py +177 -0
  706. scripts/capture_golden.py +169 -0
  707. scripts/check_graph_phantoms.py +75 -0
  708. scripts/dev/audit_doc_links.py +359 -0
  709. scripts/dev/audit_scaffold_module_tags.py +107 -0
  710. scripts/dev/backfill_doc_headers.py +328 -0
  711. scripts/dev/backfill_nav_lines.py +119 -0
  712. scripts/dev/fix_nav_placement.py +106 -0
  713. scripts/dev/inspect_sdk_options.py +45 -0
  714. scripts/dev/migrate_check_ids.py +170 -0
  715. scripts/dev/strip_purpose_blocks.py +154 -0
  716. scripts/dump_openapi.py +66 -0
  717. scripts/e2e_dispatch_tool.py +195 -0
  718. scripts/generate_manifest.py +166 -0
  719. scripts/golden_sections.py +20 -0
  720. scripts/graph_demo.py +161 -0
  721. scripts/install-git-hooks.sh +47 -0
  722. scripts/migrate_embeddings_minilm_to_bge_m3.py +84 -0
  723. scripts/operational_eval.py +445 -0
  724. scripts/probe_agent_session_resolver.py +59 -0
  725. scripts/prune_deleted_path.py +127 -0
  726. scripts/refactor_agent_dual_mode.py +171 -0
  727. scripts/refresh_skill_versions.py +302 -0
  728. scripts/regen_doc_index.py +209 -0
  729. scripts/regen_doctor_schema.py +62 -0
  730. scripts/regen_rules.py +94 -0
  731. scripts/rename_formulas_to_semantic.py +241 -0
  732. scripts/smoke_db_connections.py +183 -0
  733. scripts/smoke_doc_header.py +72 -0
  734. scripts/smoke_graph_e2e.py +374 -0
  735. scripts/smoke_sdk_dispatch.py +84 -0
  736. scripts/smoke_uid_resolver.py +164 -0
  737. scripts/verify_dispatchers.py +244 -0
  738. scripts/verify_phase_c_e2e.py +436 -0
  739. templates/__init__.py +6 -0
  740. templates/_base/Makefile.base +353 -0
  741. templates/_base/base.yaml +59 -0
  742. templates/_base/coding-os.yaml.template +39 -0
  743. templates/_base/dimension-registry.template.md +68 -0
  744. templates/_base/domain-config.template.json +46 -0
  745. templates/_base/fragments/anatomy-map.md.tmpl +11 -0
  746. templates/_base/fragments/context-discipline.md.tmpl +3 -0
  747. templates/_base/fragments/core-loop.md.tmpl +52 -0
  748. templates/_base/fragments/engineering-routing.md.tmpl +3 -0
  749. templates/_base/fragments/header.md.tmpl +6 -0
  750. templates/_base/fragments/identity.md.tmpl +3 -0
  751. templates/_base/fragments/principles.md.tmpl +3 -0
  752. templates/_base/fragments/retrieval-routing.md.tmpl +23 -0
  753. templates/_base/fragments/session-handoff.md.tmpl +3 -0
  754. templates/_base/fragments/skills.md.tmpl +3 -0
  755. templates/_base/fragments/ssot-map.md.tmpl +3 -0
  756. templates/_base/fragments/stop-conditions.md.tmpl +3 -0
  757. templates/_base/fragments/subagent-dispatch.md.tmpl +3 -0
  758. templates/_base/fragments/task-authoring.md.tmpl +69 -0
  759. templates/_base/fragments/task-logging.md.tmpl +8 -0
  760. templates/_base/fragments/tool-routing.md.tmpl +9 -0
  761. templates/_base/fragments/verification-matrix.md.tmpl +12 -0
  762. templates/_base/lang/dart/analysis_options.yaml +7 -0
  763. templates/_base/lang/php/phpcs.xml.dist +10 -0
  764. templates/_base/lang/python/pyproject.toml +19 -0
  765. templates/_base/lang/rust/clippy.toml +5 -0
  766. templates/_base/lang/rust/rustfmt.toml +3 -0
  767. templates/_base/lang/typescript/eslint.config.js +26 -0
  768. templates/_base/lang/typescript/tsconfig.json +15 -0
  769. templates/_base/lang/typescript/vitest.config.ts +10 -0
  770. templates/_base/scaffold/changes.log +1 -0
  771. templates/_base/scaffold/docs/00-index.md +55 -0
  772. templates/_base/scaffold/docs/_meta/feature-dependency-tree.md +30 -0
  773. templates/_base/scaffold/docs/_meta/foundation-map.md +56 -0
  774. templates/_base/scaffold/docs/_meta/questions.md +8 -0
  775. templates/_base/scaffold/docs/_meta/roadmap.md +33 -0
  776. templates/_base/scaffold/docs/api-contracts/00-index.md +58 -0
  777. templates/_base/scaffold/docs/api-contracts/error-format.md +58 -0
  778. templates/_base/scaffold/docs/architecture/00-index.md +42 -0
  779. templates/_base/scaffold/docs/architecture/adr/00-index.md +39 -0
  780. templates/_base/scaffold/docs/engineering/00-index.md +9 -0
  781. templates/_base/scaffold/docs/governance/00-index.md +55 -0
  782. templates/_base/scaffold/docs/governance/_templates/doc-cheat-sheet.md +202 -0
  783. templates/_base/scaffold/docs/governance/_templates/playbook-template.md +81 -0
  784. templates/_base/scaffold/docs/governance/_templates/post-mortem-template.md +85 -0
  785. templates/_base/scaffold/docs/governance/_templates/runbook-template.md +88 -0
  786. templates/_base/scaffold/docs/governance/_templates/security-review-template.md +111 -0
  787. templates/_base/scaffold/docs/governance/_templates/task-detail.md +61 -0
  788. templates/_base/scaffold/docs/governance/agent-workflow.md +101 -0
  789. templates/_base/scaffold/docs/governance/anatomy-contract.md +150 -0
  790. templates/_base/scaffold/docs/governance/critical-rules.md +224 -0
  791. templates/_base/scaffold/docs/governance/decision-records.md +58 -0
  792. templates/_base/scaffold/docs/governance/docs-first-protocol.md +157 -0
  793. templates/_base/scaffold/docs/governance/docs-system.md +151 -0
  794. templates/_base/scaffold/docs/governance/gdpr-compliance.md +66 -0
  795. templates/_base/scaffold/docs/governance/mcp-tool-inventory.md +112 -0
  796. templates/_base/scaffold/docs/governance/risk-register.md +26 -0
  797. templates/_base/scaffold/docs/governance/scaffold-boundary-contract.md +161 -0
  798. templates/_base/scaffold/docs/governance/task-lifecycle.md +125 -0
  799. templates/_base/scaffold/docs/governance/wrapper-derivation.md +50 -0
  800. templates/_base/scaffold/docs/insights/00-index.md +17 -0
  801. templates/_base/scaffold/docs/ops/00-index.md +59 -0
  802. templates/_base/scaffold/docs/ops/runbooks/00-index.md +9 -0
  803. templates/_base/scaffold/docs/playbooks/00-index.md +12 -0
  804. templates/_base/scaffold/docs/playbooks/research-validation.md +29 -0
  805. templates/_base/scaffold/docs/playbooks/security-review.md +41 -0
  806. templates/_base/scaffold/docs/prd/00-index.md +43 -0
  807. templates/_base/scaffold/docs/prd/01-snapshot-vision.md +56 -0
  808. templates/_base/scaffold/docs/workflow/workflow-guide.md +138 -0
  809. templates/_base/scaffold/src/shared/README.md +23 -0
  810. templates/_base/skill-enforcement.template.md +14 -0
  811. templates/_base/task-detail.template.md +61 -0
  812. templates/_presets/ai-saas.yaml +9 -0
  813. templates/_presets/django-next.yaml +8 -0
  814. templates/_presets/dotnet-react.yaml +8 -0
  815. templates/_presets/flutter-baas.yaml +8 -0
  816. templates/_presets/go-react.yaml +8 -0
  817. templates/_presets/hexagonal-product.yaml +16 -0
  818. templates/_presets/jamstack.yaml +8 -0
  819. templates/_presets/laravel-vue.yaml +8 -0
  820. templates/_presets/mean.yaml +8 -0
  821. templates/_presets/mern.yaml +9 -0
  822. templates/_presets/nest-angular.yaml +8 -0
  823. templates/_presets/nextjs-fastapi.yaml +10 -0
  824. templates/_presets/nuxt-fullstack.yaml +9 -0
  825. templates/_presets/pern.yaml +9 -0
  826. templates/_presets/rails-react.yaml +8 -0
  827. templates/_presets/rn-api.yaml +8 -0
  828. templates/_presets/rust-svelte.yaml +8 -0
  829. templates/_presets/spring-react.yaml +8 -0
  830. templates/_presets/t3-style.yaml +9 -0
  831. templates/_presets/tall.yaml +8 -0
  832. templates/_presets/wordpress-cms.yaml +8 -0
  833. templates/angular/rules/frontend.md +19 -0
  834. templates/angular/scaffold/docs/engineering/accessibility.md +46 -0
  835. templates/angular/scaffold/docs/engineering/angular-rules.md +35 -0
  836. templates/angular/scaffold/docs/playbooks/angular-app.md +42 -0
  837. templates/angular/scaffold/src/frontend/angular.json +52 -0
  838. templates/angular/scaffold/src/frontend/package.json +28 -0
  839. templates/angular/scaffold/src/frontend/src/app/app.component.ts +19 -0
  840. templates/angular/scaffold/src/frontend/src/app/app.config.ts +22 -0
  841. templates/angular/scaffold/src/frontend/src/app/app.routes.ts +8 -0
  842. templates/angular/scaffold/src/frontend/src/app/core/global-error-handler.ts +12 -0
  843. templates/angular/scaffold/src/frontend/src/app/health/health.component.ts +14 -0
  844. templates/angular/scaffold/src/frontend/src/app/health/health.service.spec.ts +16 -0
  845. templates/angular/scaffold/src/frontend/src/app/health/health.service.ts +10 -0
  846. templates/angular/scaffold/src/frontend/src/index.html +11 -0
  847. templates/angular/scaffold/src/frontend/src/main.ts +9 -0
  848. templates/angular/scaffold/src/frontend/src/styles.css +14 -0
  849. templates/angular/scaffold/src/frontend/tsconfig.app.json +8 -0
  850. templates/angular/scaffold/src/frontend/tsconfig.json +27 -0
  851. templates/angular/scaffold/src/frontend/tsconfig.spec.json +8 -0
  852. templates/angular/scaffold-boundary.yaml +27 -0
  853. templates/angular/skills/angular/SKILL.md +79 -0
  854. templates/angular/skills/angular/references/anatomy.md +69 -0
  855. templates/angular/stack.yaml +75 -0
  856. templates/aspnet-core/rules/backend.md +20 -0
  857. templates/aspnet-core/scaffold/docs/engineering/aspnet-core-rules.md +36 -0
  858. templates/aspnet-core/scaffold/docs/playbooks/aspnet-core-service.md +40 -0
  859. templates/aspnet-core/scaffold/src/backend/Backend.csproj +11 -0
  860. templates/aspnet-core/scaffold/src/backend/Backend.sln +27 -0
  861. templates/aspnet-core/scaffold/src/backend/Common/ExceptionHandlingMiddleware.cs +35 -0
  862. templates/aspnet-core/scaffold/src/backend/Features/Health/HealthEndpoints.cs +10 -0
  863. templates/aspnet-core/scaffold/src/backend/Features/Health/HealthService.cs +9 -0
  864. templates/aspnet-core/scaffold/src/backend/Program.cs +22 -0
  865. templates/aspnet-core/scaffold/src/backend/tests/Backend.Tests/Backend.Tests.csproj +22 -0
  866. templates/aspnet-core/scaffold/src/backend/tests/Backend.Tests/HealthServiceTests.cs +15 -0
  867. templates/aspnet-core/scaffold-boundary.yaml +24 -0
  868. templates/aspnet-core/skills/aspnet-core/SKILL.md +80 -0
  869. templates/aspnet-core/skills/aspnet-core/references/anatomy.md +65 -0
  870. templates/aspnet-core/stack.yaml +68 -0
  871. templates/astro/rules/frontend.md +20 -0
  872. templates/astro/scaffold/docs/engineering/astro-rules.md +40 -0
  873. templates/astro/scaffold/docs/playbooks/astro-app.md +57 -0
  874. templates/astro/scaffold/docs/playbooks/content-seo.md +37 -0
  875. templates/astro/scaffold/src/frontend/astro.config.mjs +10 -0
  876. templates/astro/scaffold/src/frontend/package.json +23 -0
  877. templates/astro/scaffold/src/frontend/src/components/Greeting.astro +13 -0
  878. templates/astro/scaffold/src/frontend/src/content/posts/hello.md +10 -0
  879. templates/astro/scaffold/src/frontend/src/content.config.ts +19 -0
  880. templates/astro/scaffold/src/frontend/src/lib/problem.test.ts +39 -0
  881. templates/astro/scaffold/src/frontend/src/lib/problem.ts +30 -0
  882. templates/astro/scaffold/src/frontend/src/pages/api/health.ts +13 -0
  883. templates/astro/scaffold/src/frontend/src/pages/index.astro +21 -0
  884. templates/astro/scaffold/src/frontend/tsconfig.json +9 -0
  885. templates/astro/scaffold/src/frontend/vitest.config.ts +10 -0
  886. templates/astro/scaffold-boundary.yaml +28 -0
  887. templates/astro/skills/astro/SKILL.md +71 -0
  888. templates/astro/skills/astro/references/anatomy.md +66 -0
  889. templates/astro/stack.yaml +75 -0
  890. templates/csharp-plain/scaffold/src/backend/Backend.csproj +12 -0
  891. templates/csharp-plain/scaffold/src/backend/Program.cs +1 -0
  892. templates/csharp-plain/scaffold-boundary.yaml +23 -0
  893. templates/csharp-plain/stack.yaml +50 -0
  894. templates/django/rules/backend.md +18 -0
  895. templates/django/scaffold/docs/engineering/anti-ambiguity.md +74 -0
  896. templates/django/scaffold/docs/engineering/backend-rules.md +133 -0
  897. templates/django/scaffold/docs/engineering/glossary.md +51 -0
  898. templates/django/scaffold/docs/engineering/logging-standards.md +107 -0
  899. templates/django/scaffold/docs/engineering/naming-conventions.md +68 -0
  900. templates/django/scaffold/docs/engineering/secrets-rotation-runbook.md +142 -0
  901. templates/django/scaffold/docs/playbooks/backend-api.md +119 -0
  902. templates/django/scaffold/src/backend/config/__init__.py +0 -0
  903. templates/django/scaffold/src/backend/config/settings.py +31 -0
  904. templates/django/scaffold/src/backend/config/urls.py +11 -0
  905. templates/django/scaffold/src/backend/config/wsgi.py +6 -0
  906. templates/django/scaffold/src/backend/manage.py +14 -0
  907. templates/django/scaffold/src/backend/pyproject.toml +37 -0
  908. templates/django/scaffold/src/backend/tests/test_health.py +4 -0
  909. templates/django/scaffold-boundary.yaml +25 -0
  910. templates/django/skills/python-django/SKILL.md +450 -0
  911. templates/django/skills/python-django/references/anatomy.md +117 -0
  912. templates/django/skills/python-django/scripts/new_endpoint.py +89 -0
  913. templates/django/stack.yaml +73 -0
  914. templates/fastapi/rules/backend.md +18 -0
  915. templates/fastapi/scaffold/docs/engineering/fastapi-rules.md +37 -0
  916. templates/fastapi/scaffold/docs/playbooks/fastapi-service.md +30 -0
  917. templates/fastapi/scaffold/src/backend/app/__init__.py +0 -0
  918. templates/fastapi/scaffold/src/backend/app/main.py +8 -0
  919. templates/fastapi/scaffold/src/backend/pyproject.toml +39 -0
  920. templates/fastapi/scaffold/src/backend/tests/test_health.py +11 -0
  921. templates/fastapi/scaffold-boundary.yaml +25 -0
  922. templates/fastapi/skills/python-fastapi/SKILL.md +75 -0
  923. templates/fastapi/skills/python-fastapi/references/anatomy.md +117 -0
  924. templates/fastapi/skills/python-fastapi/scripts/new_endpoint.py +101 -0
  925. templates/fastapi/stack.yaml +57 -0
  926. templates/flutter/rules/mobile.md +26 -0
  927. templates/flutter/scaffold/docs/engineering/flutter-rules.md +35 -0
  928. templates/flutter/scaffold/docs/playbooks/flutter-app.md +45 -0
  929. templates/flutter/scaffold/src/mobile/lib/core/error_mapper.dart +17 -0
  930. templates/flutter/scaffold/src/mobile/lib/core/router.dart +13 -0
  931. templates/flutter/scaffold/src/mobile/lib/main.dart +22 -0
  932. templates/flutter/scaffold/src/mobile/lib/screens/health_screen.dart +32 -0
  933. templates/flutter/scaffold/src/mobile/lib/services/health_service.dart +11 -0
  934. templates/flutter/scaffold/src/mobile/lib/state/health_provider.dart +13 -0
  935. templates/flutter/scaffold/src/mobile/pubspec.yaml +22 -0
  936. templates/flutter/scaffold/src/mobile/test/health_provider_test.dart +90 -0
  937. templates/flutter/scaffold-boundary.yaml +28 -0
  938. templates/flutter/skills/flutter/SKILL.md +76 -0
  939. templates/flutter/skills/flutter/references/anatomy.md +64 -0
  940. templates/flutter/stack.yaml +70 -0
  941. templates/go/rules/backend.md +19 -0
  942. templates/go/scaffold/docs/engineering/go-rules.md +45 -0
  943. templates/go/scaffold/docs/playbooks/go-service.md +30 -0
  944. templates/go/scaffold/src/backend/cmd/api/main.go +22 -0
  945. templates/go/scaffold/src/backend/cmd/api/main_test.go +20 -0
  946. templates/go/scaffold/src/backend/go.mod +3 -0
  947. templates/go/scaffold-boundary.yaml +25 -0
  948. templates/go/skills/go-patterns/SKILL.md +68 -0
  949. templates/go/skills/go-patterns/assets/go-checklist.md +29 -0
  950. templates/go/skills/go-patterns/references/anatomy.md +115 -0
  951. templates/go/skills/go-patterns/references/go-2026-idioms.md +92 -0
  952. templates/go/skills/go-patterns/scripts/new_endpoint.py +117 -0
  953. templates/go/skills/go-patterns/versions.json +16 -0
  954. templates/go/stack.yaml +54 -0
  955. templates/go-fiber/rules/backend.md +20 -0
  956. templates/go-fiber/scaffold/docs/engineering/fiber-rules.md +97 -0
  957. templates/go-fiber/scaffold/docs/playbooks/fiber-service.md +149 -0
  958. templates/go-fiber/scaffold/src/backend/cmd/api/main.go +21 -0
  959. templates/go-fiber/scaffold/src/backend/cmd/api/main_test.go +17 -0
  960. templates/go-fiber/scaffold/src/backend/go.mod +23 -0
  961. templates/go-fiber/scaffold/src/backend/go.sum +49 -0
  962. templates/go-fiber/scaffold-boundary.yaml +26 -0
  963. templates/go-fiber/skills/go-fiber/SKILL.md +203 -0
  964. templates/go-fiber/skills/go-fiber/assets/fiber-checklist.md +26 -0
  965. templates/go-fiber/skills/go-fiber/references/anatomy.md +116 -0
  966. templates/go-fiber/skills/go-fiber/references/fiber-v3-patterns.md +89 -0
  967. templates/go-fiber/skills/go-fiber/scripts/new_endpoint.py +107 -0
  968. templates/go-fiber/skills/go-fiber/versions.json +16 -0
  969. templates/go-fiber/stack.yaml +59 -0
  970. templates/go-plain/scaffold/src/backend/go.mod +3 -0
  971. templates/go-plain/scaffold/src/backend/main.go +7 -0
  972. templates/go-plain/scaffold-boundary.yaml +22 -0
  973. templates/go-plain/stack.yaml +51 -0
  974. templates/java-plain/scaffold/src/backend/mvnw +302 -0
  975. templates/java-plain/scaffold/src/backend/pom.xml +41 -0
  976. templates/java-plain/scaffold/src/backend/src/main/java/com/example/app/Main.java +10 -0
  977. templates/java-plain/scaffold-boundary.yaml +23 -0
  978. templates/java-plain/stack.yaml +51 -0
  979. templates/laravel/rules/backend.md +20 -0
  980. templates/laravel/scaffold/docs/engineering/laravel-rules.md +28 -0
  981. templates/laravel/scaffold/docs/playbooks/laravel-service.md +28 -0
  982. templates/laravel/scaffold/src/backend/app/Exceptions/Handler.php +27 -0
  983. templates/laravel/scaffold/src/backend/app/Http/Controllers/HealthController.php +15 -0
  984. templates/laravel/scaffold/src/backend/app/Support/HealthStatus.php +14 -0
  985. templates/laravel/scaffold/src/backend/composer.json +24 -0
  986. templates/laravel/scaffold/src/backend/phpunit.xml +10 -0
  987. templates/laravel/scaffold/src/backend/public/index.php +8 -0
  988. templates/laravel/scaffold/src/backend/routes/api.php +7 -0
  989. templates/laravel/scaffold/src/backend/tests/Unit/HealthStatusTest.php +16 -0
  990. templates/laravel/scaffold-boundary.yaml +23 -0
  991. templates/laravel/skills/laravel/SKILL.md +56 -0
  992. templates/laravel/skills/laravel/references/anatomy.md +65 -0
  993. templates/laravel/stack.yaml +66 -0
  994. templates/meta/rules/graph-first.md +27 -0
  995. templates/meta/rules/hook-author.md +19 -0
  996. templates/meta/rules/mcp-tool-author.md +18 -0
  997. templates/meta/rules/meta-engineering.md +17 -0
  998. templates/meta/scaffold-boundary.yaml +55 -0
  999. templates/meta/skills/claude-sdk-integration/SKILL.md +163 -0
  1000. templates/meta/skills/claude-sdk-integration/assets/sdk-checklist.md +27 -0
  1001. templates/meta/skills/claude-sdk-integration/scripts/check_model_ids.py +97 -0
  1002. templates/meta/skills/graph-os-authoring/SKILL.md +278 -0
  1003. templates/meta/skills/graph-os-authoring/assets/graph-os-checklist.md +25 -0
  1004. templates/meta/skills/graph-os-authoring/scripts/new_extractor.py +76 -0
  1005. templates/meta/skills/hook-authoring/SKILL.md +292 -0
  1006. templates/meta/skills/hook-authoring/assets/hook-checklist.md +30 -0
  1007. templates/meta/skills/hook-authoring/scripts/new_hook.sh +75 -0
  1008. templates/meta/skills/mcp-tool-authoring/SKILL.md +301 -0
  1009. templates/meta/skills/mcp-tool-authoring/assets/mcp-tool-checklist.md +29 -0
  1010. templates/meta/skills/mcp-tool-authoring/scripts/new_tool.py +74 -0
  1011. templates/meta/skills/meta-engineering/SKILL.md +151 -0
  1012. templates/meta/skills/meta-engineering/assets/meta-edit-checklist.md +28 -0
  1013. templates/meta/skills/meta-engineering/scripts/which_layer.py +61 -0
  1014. templates/meta/skills/python-meta-server/SKILL.md +162 -0
  1015. templates/meta/skills/python-meta-server/assets/meta-server-checklist.md +28 -0
  1016. templates/meta/skills/python-meta-server/scripts/check_envelope.py +91 -0
  1017. templates/meta/skills/react-vite-hub/SKILL.md +140 -0
  1018. templates/meta/skills/react-vite-hub/assets/hub-ui-checklist.md +23 -0
  1019. templates/meta/skills/react-vite-hub/scripts/check_vite_env.py +73 -0
  1020. templates/meta/stack.yaml +119 -0
  1021. templates/nestjs/rules/backend.md +20 -0
  1022. templates/nestjs/scaffold/docs/engineering/nestjs-rules.md +33 -0
  1023. templates/nestjs/scaffold/docs/playbooks/nestjs-service.md +39 -0
  1024. templates/nestjs/scaffold/src/backend/nest-cli.json +5 -0
  1025. templates/nestjs/scaffold/src/backend/package.json +28 -0
  1026. templates/nestjs/scaffold/src/backend/src/app.module.ts +9 -0
  1027. templates/nestjs/scaffold/src/backend/src/common/all-exceptions.filter.ts +59 -0
  1028. templates/nestjs/scaffold/src/backend/src/health/health.controller.ts +14 -0
  1029. templates/nestjs/scaffold/src/backend/src/health/health.module.ts +10 -0
  1030. templates/nestjs/scaffold/src/backend/src/health/health.service.spec.ts +21 -0
  1031. templates/nestjs/scaffold/src/backend/src/health/health.service.ts +9 -0
  1032. templates/nestjs/scaffold/src/backend/src/main.ts +26 -0
  1033. templates/nestjs/scaffold/src/backend/tsconfig.json +16 -0
  1034. templates/nestjs/scaffold/src/backend/vitest.config.ts +9 -0
  1035. templates/nestjs/scaffold-boundary.yaml +25 -0
  1036. templates/nestjs/skills/nestjs/SKILL.md +67 -0
  1037. templates/nestjs/skills/nestjs/references/anatomy.md +65 -0
  1038. templates/nestjs/stack.yaml +68 -0
  1039. templates/nextjs/rules/frontend.md +18 -0
  1040. templates/nextjs/scaffold/docs/design/00-index.md +23 -0
  1041. templates/nextjs/scaffold/docs/design/colors-tokens.md +141 -0
  1042. templates/nextjs/scaffold/docs/design/components-patterns.md +159 -0
  1043. templates/nextjs/scaffold/docs/design/motion-accessibility.md +137 -0
  1044. templates/nextjs/scaffold/docs/design/typography-spacing.md +107 -0
  1045. templates/nextjs/scaffold/docs/engineering/accessibility-web.md +56 -0
  1046. templates/nextjs/scaffold/docs/engineering/copywriting-standard.md +102 -0
  1047. templates/nextjs/scaffold/docs/engineering/formatting-rules.md +89 -0
  1048. templates/nextjs/scaffold/docs/engineering/frontend-rendering-rules.md +80 -0
  1049. templates/nextjs/scaffold/docs/engineering/frontend-rules.md +183 -0
  1050. templates/nextjs/scaffold/docs/engineering/i18n-policy.md +99 -0
  1051. templates/nextjs/scaffold/docs/pages-content-spec/00-index.md +78 -0
  1052. templates/nextjs/scaffold/docs/playbooks/content-seo.md +55 -0
  1053. templates/nextjs/scaffold/docs/playbooks/docs-governance.md +51 -0
  1054. templates/nextjs/scaffold/docs/playbooks/frontend-ui.md +63 -0
  1055. templates/nextjs/scaffold/src/frontend/app/layout.tsx +14 -0
  1056. templates/nextjs/scaffold/src/frontend/app/page.tsx +3 -0
  1057. templates/nextjs/scaffold/src/frontend/eslint.config.js +17 -0
  1058. templates/nextjs/scaffold/src/frontend/lib/greeting.test.ts +9 -0
  1059. templates/nextjs/scaffold/src/frontend/lib/greeting.ts +3 -0
  1060. templates/nextjs/scaffold/src/frontend/package.json +28 -0
  1061. templates/nextjs/scaffold/src/frontend/tsconfig.json +18 -0
  1062. templates/nextjs/scaffold/src/frontend/vitest.config.ts +10 -0
  1063. templates/nextjs/scaffold-boundary.yaml +30 -0
  1064. templates/nextjs/skills/nextjs-react/SKILL.md +485 -0
  1065. templates/nextjs/skills/nextjs-react/references/anatomy.md +116 -0
  1066. templates/nextjs/skills/nextjs-react/scripts/new_component.py +72 -0
  1067. templates/nextjs/stack.yaml +81 -0
  1068. templates/node-express/rules/backend.md +20 -0
  1069. templates/node-express/scaffold/docs/engineering/express-rules.md +30 -0
  1070. templates/node-express/scaffold/docs/playbooks/express-service.md +35 -0
  1071. templates/node-express/scaffold/src/backend/package.json +24 -0
  1072. templates/node-express/scaffold/src/backend/src/index.ts +17 -0
  1073. templates/node-express/scaffold/src/backend/src/middleware/error-handler.ts +12 -0
  1074. templates/node-express/scaffold/src/backend/src/routes/health.test.ts +33 -0
  1075. templates/node-express/scaffold/src/backend/src/routes/health.ts +7 -0
  1076. templates/node-express/scaffold/src/backend/tsconfig.json +15 -0
  1077. templates/node-express/scaffold/src/backend/types/express-bootstrap.d.ts +21 -0
  1078. templates/node-express/scaffold-boundary.yaml +25 -0
  1079. templates/node-express/skills/node-express/SKILL.md +70 -0
  1080. templates/node-express/skills/node-express/references/anatomy.md +63 -0
  1081. templates/node-express/stack.yaml +63 -0
  1082. templates/python/scaffold/docs/engineering/python-rules.md +27 -0
  1083. templates/python/scaffold/docs/playbooks/python-library.md +35 -0
  1084. templates/python/stack.yaml +60 -0
  1085. templates/rails/rules/backend.md +10 -0
  1086. templates/rails/scaffold/docs/engineering/rails-rules.md +33 -0
  1087. templates/rails/scaffold/docs/playbooks/rails-service.md +42 -0
  1088. templates/rails/scaffold/src/backend/Gemfile +12 -0
  1089. templates/rails/scaffold/src/backend/app/controllers/application_controller.rb +26 -0
  1090. templates/rails/scaffold/src/backend/app/controllers/health_controller.rb +6 -0
  1091. templates/rails/scaffold/src/backend/app/models/health.rb +6 -0
  1092. templates/rails/scaffold/src/backend/config/application.rb +12 -0
  1093. templates/rails/scaffold/src/backend/config/boot.rb +3 -0
  1094. templates/rails/scaffold/src/backend/config/routes.rb +4 -0
  1095. templates/rails/scaffold/src/backend/config.ru +5 -0
  1096. templates/rails/scaffold/src/backend/spec/rails_helper.rb +18 -0
  1097. templates/rails/scaffold/src/backend/spec/requests/health_spec.rb +24 -0
  1098. templates/rails/scaffold-boundary.yaml +25 -0
  1099. templates/rails/skills/rails/SKILL.md +62 -0
  1100. templates/rails/skills/rails/references/anatomy.md +71 -0
  1101. templates/rails/stack.yaml +72 -0
  1102. templates/react-native/rules/mobile.md +26 -0
  1103. templates/react-native/scaffold/docs/engineering/accessibility-mobile.md +95 -0
  1104. templates/react-native/scaffold/docs/engineering/mobile-rules.md +56 -0
  1105. templates/react-native/scaffold/docs/engineering/offline-first.md +61 -0
  1106. templates/react-native/scaffold/docs/playbooks/mobile-app.md +49 -0
  1107. templates/react-native/scaffold/src/mobile/App.tsx +9 -0
  1108. templates/react-native/scaffold/src/mobile/eslint.config.js +17 -0
  1109. templates/react-native/scaffold/src/mobile/package.json +23 -0
  1110. templates/react-native/scaffold/src/mobile/src/greeting.test.ts +9 -0
  1111. templates/react-native/scaffold/src/mobile/src/greeting.ts +3 -0
  1112. templates/react-native/scaffold/src/mobile/tsconfig.json +17 -0
  1113. templates/react-native/scaffold/src/mobile/vitest.config.ts +10 -0
  1114. templates/react-native/scaffold-boundary.yaml +30 -0
  1115. templates/react-native/skills/react-native-mobile/SKILL.md +119 -0
  1116. templates/react-native/skills/react-native-mobile/assets/rn-mobile-checklist.md +28 -0
  1117. templates/react-native/skills/react-native-mobile/references/anatomy.md +140 -0
  1118. templates/react-native/skills/react-native-mobile/references/rn-2026-practices.md +54 -0
  1119. templates/react-native/skills/react-native-mobile/scripts/new_screen.py +73 -0
  1120. templates/react-native/skills/react-native-mobile/versions.json +16 -0
  1121. templates/react-native/skills/react-native-patterns/SKILL.md +512 -0
  1122. templates/react-native/skills/react-native-patterns/assets/rn-review-checklist.md +26 -0
  1123. templates/react-native/skills/react-native-patterns/references/anatomy.md +62 -0
  1124. templates/react-native/skills/react-native-patterns/references/list-performance.md +70 -0
  1125. templates/react-native/skills/react-native-patterns/scripts/scan_rn_perf.py +77 -0
  1126. templates/react-native/stack.yaml +70 -0
  1127. templates/ruby-plain/scaffold/src/backend/Gemfile +8 -0
  1128. templates/ruby-plain/scaffold/src/backend/main.rb +3 -0
  1129. templates/ruby-plain/scaffold-boundary.yaml +23 -0
  1130. templates/ruby-plain/stack.yaml +50 -0
  1131. templates/rust-axum/rules/backend.md +20 -0
  1132. templates/rust-axum/scaffold/docs/engineering/rust-axum-rules.md +35 -0
  1133. templates/rust-axum/scaffold/docs/playbooks/rust-axum-service.md +43 -0
  1134. templates/rust-axum/scaffold/src/backend/Cargo.toml +20 -0
  1135. templates/rust-axum/scaffold/src/backend/src/app.rs +12 -0
  1136. templates/rust-axum/scaffold/src/backend/src/error.rs +48 -0
  1137. templates/rust-axum/scaffold/src/backend/src/main.rs +24 -0
  1138. templates/rust-axum/scaffold/src/backend/src/routes/health.rs +37 -0
  1139. templates/rust-axum/scaffold/src/backend/src/routes/mod.rs +2 -0
  1140. templates/rust-axum/scaffold-boundary.yaml +25 -0
  1141. templates/rust-axum/skills/rust/SKILL.md +73 -0
  1142. templates/rust-axum/skills/rust/references/anatomy.md +63 -0
  1143. templates/rust-axum/stack.yaml +65 -0
  1144. templates/rust-plain/scaffold/src/backend/Cargo.toml +6 -0
  1145. templates/rust-plain/scaffold/src/backend/src/main.rs +3 -0
  1146. templates/rust-plain/scaffold-boundary.yaml +23 -0
  1147. templates/rust-plain/stack.yaml +51 -0
  1148. templates/spring-boot/rules/backend.md +20 -0
  1149. templates/spring-boot/scaffold/docs/engineering/spring-boot-rules.md +35 -0
  1150. templates/spring-boot/scaffold/docs/playbooks/spring-boot-service.md +45 -0
  1151. templates/spring-boot/scaffold/src/backend/mvnw +302 -0
  1152. templates/spring-boot/scaffold/src/backend/pom.xml +69 -0
  1153. templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/Application.java +16 -0
  1154. templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/common/GlobalExceptionHandler.java +31 -0
  1155. templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/health/HealthController.java +22 -0
  1156. templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/health/HealthService.java +12 -0
  1157. templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/health/HealthStatus.java +4 -0
  1158. templates/spring-boot/scaffold/src/backend/src/test/java/com/example/app/health/HealthServiceTest.java +15 -0
  1159. templates/spring-boot/scaffold-boundary.yaml +26 -0
  1160. templates/spring-boot/skills/spring-boot/SKILL.md +84 -0
  1161. templates/spring-boot/skills/spring-boot/references/anatomy.md +63 -0
  1162. templates/spring-boot/stack.yaml +65 -0
  1163. templates/svelte-sveltekit/rules/frontend.md +20 -0
  1164. templates/svelte-sveltekit/scaffold/docs/engineering/svelte-sveltekit-rules.md +37 -0
  1165. templates/svelte-sveltekit/scaffold/docs/playbooks/svelte-sveltekit-app.md +36 -0
  1166. templates/svelte-sveltekit/scaffold/src/frontend/package.json +22 -0
  1167. templates/svelte-sveltekit/scaffold/src/frontend/src/app.html +12 -0
  1168. templates/svelte-sveltekit/scaffold/src/frontend/src/hooks.server.ts +14 -0
  1169. templates/svelte-sveltekit/scaffold/src/frontend/src/lib/components/Greeting.svelte +6 -0
  1170. templates/svelte-sveltekit/scaffold/src/frontend/src/lib/stores/count.test.ts +26 -0
  1171. templates/svelte-sveltekit/scaffold/src/frontend/src/lib/stores/count.ts +4 -0
  1172. templates/svelte-sveltekit/scaffold/src/frontend/src/routes/+layout.svelte +24 -0
  1173. templates/svelte-sveltekit/scaffold/src/frontend/src/routes/+page.svelte +9 -0
  1174. templates/svelte-sveltekit/scaffold/src/frontend/src/routes/+page.ts +7 -0
  1175. templates/svelte-sveltekit/scaffold/src/frontend/src/routes/health/+server.ts +7 -0
  1176. templates/svelte-sveltekit/scaffold/src/frontend/svelte.config.js +10 -0
  1177. templates/svelte-sveltekit/scaffold/src/frontend/tsconfig.json +7 -0
  1178. templates/svelte-sveltekit/scaffold/src/frontend/vite.config.ts +7 -0
  1179. templates/svelte-sveltekit/scaffold/src/frontend/vitest.config.ts +11 -0
  1180. templates/svelte-sveltekit/scaffold-boundary.yaml +29 -0
  1181. templates/svelte-sveltekit/skills/svelte/SKILL.md +90 -0
  1182. templates/svelte-sveltekit/skills/svelte/references/anatomy.md +62 -0
  1183. templates/svelte-sveltekit/stack.yaml +70 -0
  1184. templates/typescript-plain/scaffold/src/index.ts +3 -0
  1185. templates/typescript-plain/scaffold/tsconfig.json +13 -0
  1186. templates/typescript-plain/scaffold-boundary.yaml +22 -0
  1187. templates/typescript-plain/stack.yaml +44 -0
  1188. templates/vue-nuxt/rules/frontend.md +19 -0
  1189. templates/vue-nuxt/scaffold/docs/engineering/nuxt-rules.md +30 -0
  1190. templates/vue-nuxt/scaffold/docs/playbooks/nuxt-app.md +29 -0
  1191. templates/vue-nuxt/scaffold/src/frontend/app.vue +3 -0
  1192. templates/vue-nuxt/scaffold/src/frontend/nuxt.config.ts +11 -0
  1193. templates/vue-nuxt/scaffold/src/frontend/package.json +20 -0
  1194. templates/vue-nuxt/scaffold/src/frontend/pages/index.test.ts +19 -0
  1195. templates/vue-nuxt/scaffold/src/frontend/pages/index.vue +11 -0
  1196. templates/vue-nuxt/scaffold/src/frontend/vitest.config.ts +12 -0
  1197. templates/vue-nuxt/scaffold-boundary.yaml +26 -0
  1198. templates/vue-nuxt/skills/vue-nuxt/SKILL.md +57 -0
  1199. templates/vue-nuxt/skills/vue-nuxt/references/anatomy.md +60 -0
  1200. templates/vue-nuxt/stack.yaml +61 -0
  1201. templates/wordpress/rules/backend.md +19 -0
  1202. templates/wordpress/scaffold/docs/engineering/wordpress-rules.md +28 -0
  1203. templates/wordpress/scaffold/docs/playbooks/wordpress-service.md +29 -0
  1204. templates/wordpress/scaffold/src/backend/composer.json +17 -0
  1205. templates/wordpress/scaffold/src/backend/phpcs.xml.dist +11 -0
  1206. templates/wordpress/scaffold/src/backend/phpunit.xml +10 -0
  1207. templates/wordpress/scaffold/src/backend/plugin/inc/health.php +8 -0
  1208. templates/wordpress/scaffold/src/backend/plugin/plugin.php +28 -0
  1209. templates/wordpress/scaffold/src/backend/tests/HealthStatusTest.php +15 -0
  1210. templates/wordpress/scaffold/src/backend/theme/functions.php +18 -0
  1211. templates/wordpress/scaffold/src/backend/theme/style.css +11 -0
  1212. templates/wordpress/scaffold-boundary.yaml +23 -0
  1213. templates/wordpress/skills/wordpress/SKILL.md +110 -0
  1214. templates/wordpress/skills/wordpress/assets/wp-checklist.md +28 -0
  1215. templates/wordpress/skills/wordpress/references/wp-development.md +75 -0
  1216. templates/wordpress/skills/wordpress/references/wp-security.md +68 -0
  1217. templates/wordpress/skills/wordpress/scripts/scan_wp_smells.py +91 -0
  1218. templates/wordpress/skills/wordpress/versions.json +16 -0
  1219. templates/wordpress/stack.yaml +60 -0
  1220. thinking_os/__init__.py +1 -0
  1221. thinking_os/_agent_markers.py +32 -0
  1222. thinking_os/background.py +405 -0
  1223. thinking_os/bootstrap_outcomes.py +200 -0
  1224. thinking_os/budget.py +302 -0
  1225. thinking_os/capture.py +495 -0
  1226. thinking_os/cognition.py +516 -0
  1227. thinking_os/cognition_schemas.py +517 -0
  1228. thinking_os/compress.py +192 -0
  1229. thinking_os/concepts.py +233 -0
  1230. thinking_os/dashboard.py +159 -0
  1231. thinking_os/database.py +2883 -0
  1232. thinking_os/decay.py +393 -0
  1233. thinking_os/digest.py +295 -0
  1234. thinking_os/dispatcher.py +192 -0
  1235. thinking_os/dispatcher_helpers.py +48 -0
  1236. thinking_os/dispatchers/__init__.py +3 -0
  1237. thinking_os/dispatchers/default.py +47 -0
  1238. thinking_os/distill.py +192 -0
  1239. thinking_os/doc_indexer.py +905 -0
  1240. thinking_os/embeddings.py +943 -0
  1241. thinking_os/formula_composer.py +556 -0
  1242. thinking_os/gate_marker.py +75 -0
  1243. thinking_os/graph.py +296 -0
  1244. thinking_os/graph_indexer.py +360 -0
  1245. thinking_os/health_check.py +517 -0
  1246. thinking_os/impact.py +119 -0
  1247. thinking_os/memory_gc.py +356 -0
  1248. thinking_os/migrator_embeddings.py +318 -0
  1249. thinking_os/precision.py +194 -0
  1250. thinking_os/record_outcome.py +399 -0
  1251. thinking_os/repair.py +105 -0
  1252. thinking_os/retrieval_quality.py +239 -0
  1253. thinking_os/roles_state.py +168 -0
  1254. thinking_os/sanitizer.py +320 -0
  1255. thinking_os/server.py +3160 -0
  1256. thinking_os/session_enrich.py +272 -0
  1257. thinking_os/session_observe_worker.py +111 -0
  1258. thinking_os/session_startup.py +74 -0
  1259. thinking_os/session_summary.py +245 -0
  1260. thinking_os/task_analyzer.py +462 -0
  1261. thinking_os/task_parser.py +342 -0
  1262. thinking_os/task_sync.py +73 -0
  1263. thinking_os/tools/__init__.py +6 -0
  1264. thinking_os/tools/_shared.py +947 -0
  1265. thinking_os/tools/cognition.py +1867 -0
  1266. thinking_os/tools/docs.py +770 -0
  1267. thinking_os/tools/learning.py +2078 -0
  1268. thinking_os/tools/logs.py +79 -0
  1269. thinking_os/tools/memory.py +840 -0
  1270. thinking_os/tools/metrics.py +200 -0
  1271. thinking_os/tools/retrieve.py +415 -0
  1272. thinking_os/tools/routing.py +658 -0
  1273. thinking_os/tools/tasks.py +449 -0
  1274. thinking_os/tools/trajectory.py +181 -0
  1275. thinking_os/tracing.py +235 -0
  1276. web/__init__.py +5 -0
  1277. web/_cache.py +118 -0
  1278. web/_deps.py +56 -0
  1279. web/_envelope.py +85 -0
  1280. web/_project_context.py +140 -0
  1281. web/chat_providers.py +108 -0
  1282. web/init_jobs.py +216 -0
  1283. web/routes/__init__.py +25 -0
  1284. web/routes/_bounded_read.py +76 -0
  1285. web/routes/board.py +1089 -0
  1286. web/routes/cognition.py +1838 -0
  1287. web/routes/config.py +635 -0
  1288. web/routes/graph.py +513 -0
  1289. web/routes/health.py +180 -0
  1290. web/routes/hooks.py +288 -0
  1291. web/routes/hub.py +1219 -0
  1292. web/routes/logs.py +374 -0
  1293. web/routes/metrics.py +43 -0
  1294. web/routes/observability.py +400 -0
  1295. web/routes/patterns.py +227 -0
  1296. web/routes/presence.py +609 -0
  1297. web/routes/roles.py +446 -0
  1298. web/routes/scheduled.py +261 -0
  1299. web/routes/search.py +238 -0
  1300. web/routes/sessions.py +220 -0
  1301. web/routes/settings.py +363 -0
  1302. web/routes/stream.py +547 -0
  1303. web/security.py +157 -0
  1304. web/server.py +283 -0
cli/pr_commands.py ADDED
@@ -0,0 +1,2024 @@
1
+ """cos pr — pr-mode multi-agent git executor (TASK-517).
2
+
3
+ Thin, idempotent subcommands the agent drives from its OWN turn loop (never a
4
+ kernel daemon — hooks can't loop, MCP polling blocks the server):
5
+
6
+ cos pr preflight — capability check (remote + gh + required CI); degrade signal
7
+ cos pr open — isolate: claim/derive a session, create a worktree + agents/* branch
8
+ cos pr submit — publish: rebase onto FETCH_HEAD, sha-pinned lease push, PR, auto-merge
9
+ cos pr status — list this repo's pr-mode worktrees / branches / open PRs
10
+ cos pr cleanup — remove the worktree + delete the branch + prune
11
+
12
+ All gh-coupled code lives here in src/cli (P2/P8 — src/core stays agent/host
13
+ agnostic; it reaches every consumer via live symlinks). When a capability is
14
+ missing the executor degrades to the trunk publish path instead of failing
15
+ mid-loop. SPEC: docs/playbooks/pr-workflow.md.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import re
25
+ import socket
26
+ import subprocess
27
+ import sys
28
+ import time
29
+ from pathlib import Path
30
+
31
+ try:
32
+ import fcntl
33
+ except ImportError: # non-POSIX (Windows) — the reaper lock degrades to a no-op
34
+ fcntl = None # type: ignore[assignment]
35
+
36
+ import click
37
+
38
+ _REPO_ROOT = Path(__file__).resolve().parent.parent.parent
39
+ if str(_REPO_ROOT) not in sys.path:
40
+ sys.path.insert(0, str(_REPO_ROOT))
41
+
42
+
43
+ # --------------------------------------------------------------------------- #
44
+ # subprocess + git/gh helpers
45
+ # --------------------------------------------------------------------------- #
46
+ def _run(
47
+ args: list[str], *, cwd: str | Path | None = None, timeout: int | None = None
48
+ ) -> subprocess.CompletedProcess[str]:
49
+ # Bound every gh/git call so a stalled network can never wedge the agent's
50
+ # turn loop (the executor must stay non-blocking) — review finding 10.
51
+ if timeout is None:
52
+ try:
53
+ timeout = max(1, int(os.environ.get("COS_PR_SUBPROCESS_TIMEOUT", "120")))
54
+ except ValueError:
55
+ timeout = 120
56
+ try:
57
+ return subprocess.run(
58
+ args,
59
+ cwd=str(cwd) if cwd else None,
60
+ capture_output=True,
61
+ text=True,
62
+ check=False,
63
+ timeout=timeout,
64
+ )
65
+ except subprocess.TimeoutExpired as exc:
66
+ out = exc.stdout if isinstance(exc.stdout, str) else ""
67
+ return subprocess.CompletedProcess(
68
+ args, returncode=124, stdout=out, stderr=f"timed out after {timeout}s"
69
+ )
70
+
71
+
72
+ def _git(args: list[str], *, cwd: str | Path) -> subprocess.CompletedProcess[str]:
73
+ return _run(["git", "-C", str(cwd), *args])
74
+
75
+
76
+ def _git_out(args: list[str], *, cwd: str | Path) -> str:
77
+ proc = _git(args, cwd=cwd)
78
+ return proc.stdout.strip() if proc.returncode == 0 else ""
79
+
80
+
81
+ def _commit_count(cwd: str | Path, rev_range: str) -> int:
82
+ # 0 on any error (unresolved range) so the local-rung report fails toward
83
+ # "nothing to integrate" rather than a crash.
84
+ out = _git_out(["rev-list", "--count", rev_range], cwd=cwd)
85
+ try:
86
+ return int(out)
87
+ except ValueError:
88
+ return 0
89
+
90
+
91
+ def _toplevel(start: str | Path) -> str | None:
92
+ proc = _run(["git", "-C", str(start), "rev-parse", "--show-toplevel"])
93
+ return proc.stdout.strip() or None if proc.returncode == 0 else None
94
+
95
+
96
+ def _sanitize(token: str) -> str:
97
+ return re.sub(r"[^A-Za-z0-9._-]+", "-", token).strip("-") or "x"
98
+
99
+
100
+ def _repo_slug(repo_root: str) -> str:
101
+ real = os.path.realpath(repo_root)
102
+ digest = hashlib.sha256(real.encode()).hexdigest()[:8]
103
+ return f"{_sanitize(Path(real).name)}-{digest}"
104
+
105
+
106
+ def _worktree_root(repo_root: str) -> Path:
107
+ base = os.environ.get("COS_WORKTREE_ROOT") or str(Path.home() / ".coding-os" / "worktrees")
108
+ return Path(base) / _repo_slug(repo_root)
109
+
110
+
111
+ def _main_repo_root(repo: str) -> str:
112
+ # The main checkout owns the one hub-settings.json every worktree shares. A
113
+ # linked worktree's --git-common-dir resolves (relative to the worktree) to
114
+ # <main>/.git, whose parent is the main repo; the main checkout returns a bare
115
+ # ".git" and the parent collapses to repo itself. SPEC: pr-workflow.md § 3.
116
+ common = _git_out(["rev-parse", "--git-common-dir"], cwd=repo)
117
+ if not common:
118
+ return repo
119
+ common_path = Path(common)
120
+ if not common_path.is_absolute():
121
+ common_path = (Path(repo) / common_path).resolve()
122
+ return str(common_path.parent) if common_path.name == ".git" else repo
123
+
124
+
125
+ def _git_settings(repo: str) -> dict:
126
+ # Self-read the consumer's git_settings: cos-env.sh exports COS_GIT_* only into
127
+ # hook subprocesses, so the agent's `cos pr` shell has none — without this the
128
+ # configured rung/branch is silently ignored. Best-effort: any read error falls
129
+ # through to the env/default in the callers below.
130
+ settings_path = Path(_main_repo_root(repo)) / ".coding-os" / "hub-settings.json"
131
+ if not settings_path.exists():
132
+ return {}
133
+ try:
134
+ raw = json.loads(settings_path.read_text())
135
+ except Exception:
136
+ return {}
137
+ section = raw.get("git_settings")
138
+ return section if isinstance(section, dict) else {}
139
+
140
+
141
+ def _integration_branch(repo: str | None = None) -> str:
142
+ # Explicit env var always wins; else the consumer's saved integration_branch.
143
+ env = os.environ.get("COS_GIT_INTEGRATION_BRANCH")
144
+ if env:
145
+ return env
146
+ if repo is not None:
147
+ branch = _git_settings(repo).get("integration_branch")
148
+ if isinstance(branch, str) and branch:
149
+ return branch
150
+ return "main"
151
+
152
+
153
+ _AUTONOMY_LEVELS = ("local", "local_autonomous", "draft", "auto_merge", "autonomous")
154
+
155
+
156
+ def _autonomy_level(repo: str | None = None) -> str:
157
+ # Trust Spectrum: draft never arms auto-merge; auto_merge/autonomous do.
158
+ # Explicit env var wins; else the consumer's saved autonomy_level. The Hub API
159
+ # edge validates the rung (Literal), but hub-settings.json can also be written
160
+ # by the CLI or by hand — so validate HERE, where the value is consumed, and
161
+ # fall back to the safe 'draft' on an unknown rung rather than letting a typo
162
+ # silently behave as draft while reporting itself as the typo'd value.
163
+ raw = ""
164
+ env = os.environ.get("COS_GIT_AUTONOMY")
165
+ if env and env.strip():
166
+ raw = env.strip()
167
+ elif repo is not None:
168
+ level = _git_settings(repo).get("autonomy_level")
169
+ if isinstance(level, str) and level.strip():
170
+ raw = level.strip()
171
+ if not raw:
172
+ return "draft"
173
+ if raw not in _AUTONOMY_LEVELS:
174
+ click.echo(
175
+ f"cos pr: unknown autonomy_level {raw!r} — falling back to 'draft' "
176
+ f"(valid: {', '.join(_AUTONOMY_LEVELS)})",
177
+ err=True,
178
+ )
179
+ return "draft"
180
+ return raw
181
+
182
+
183
+ def _agent_session() -> str:
184
+ try:
185
+ from cli.board_commands import _agent_session_id
186
+
187
+ sid = _agent_session_id()
188
+ except Exception: # board_os optional — never break `cos pr` on its absence
189
+ sid = None
190
+ sid = sid or os.environ.get("COS_AGENT_SESSION_ID") or os.environ.get("COS_PANEL_ID")
191
+ # Unique per process when no session id resolves — a shared constant would
192
+ # collide branches/worktrees across concurrent agents (review finding 6).
193
+ return _sanitize(sid) if sid else f"pid-{os.getpid()}"
194
+
195
+
196
+ # --------------------------------------------------------------------------- #
197
+ # capability preflight
198
+ # --------------------------------------------------------------------------- #
199
+ def _has_remote(repo: str) -> bool:
200
+ return bool(_git_out(["remote"], cwd=repo))
201
+
202
+
203
+ def _gh_ready() -> bool:
204
+ from shutil import which
205
+
206
+ if which("gh") is None:
207
+ return False
208
+ return _run(["gh", "auth", "status"]).returncode == 0
209
+
210
+
211
+ def _has_required_check(repo: str, integration: str) -> bool:
212
+ # precondition for safely arming auto-merge; best-effort, False on any doubt
213
+ if not _gh_ready():
214
+ return False
215
+ slug = _git_out(["config", "--get", "remote.origin.url"], cwd=repo)
216
+ if not slug:
217
+ return False
218
+ # cwd=repo so gh resolves the {owner}/{repo} placeholder from THIS repo's remote,
219
+ # not the process cwd — a submit run from another checkout would else probe the
220
+ # wrong repo's branch protection (D4). Every sibling gh call already scopes cwd.
221
+ proc = _run(
222
+ [
223
+ "gh",
224
+ "api",
225
+ f"repos/{{owner}}/{{repo}}/branches/{integration}/protection/required_status_checks",
226
+ ],
227
+ cwd=repo,
228
+ )
229
+ return proc.returncode == 0
230
+
231
+
232
+ def _unprotected_warning(integration: str) -> str:
233
+ return (
234
+ f"unprotected integration branch '{integration}': no GitHub branch protection / required "
235
+ f"check detected — the client-side branch-guard is the ONLY barrier, and any human, GUI, "
236
+ f"or hook-bypassed agent can push directly to '{integration}'. Set up a GitHub ruleset "
237
+ f"(require a PR + required status checks + block direct pushes) so the server enforces the "
238
+ f"wall (pr-workflow.md §11)."
239
+ )
240
+
241
+
242
+ def _preflight(repo: str, integration: str) -> dict:
243
+ remote = _has_remote(repo)
244
+ gh = _gh_ready()
245
+ required = _has_required_check(repo, integration) if (remote and gh) else False
246
+ # A reachable forge with no required check = the integration branch has no server-side
247
+ # wall, so the client branch-guard is the only barrier (the Layer-0 legibility gap).
248
+ unprotected_integration = remote and not required
249
+ missing = [
250
+ name
251
+ for name, present in (("remote", remote), ("gh", gh), ("required-ci", required))
252
+ if not present
253
+ ]
254
+ return {
255
+ "remote": remote,
256
+ "gh": gh,
257
+ "required_check": required,
258
+ "pr_ok": remote and gh,
259
+ "unprotected_integration": unprotected_integration,
260
+ "missing": missing,
261
+ }
262
+
263
+
264
+ def _branches(repo: str) -> list[str]:
265
+ # Local heads + origin remotes, de-duplicated to bare names — the source for
266
+ # the Hub branch dropdowns so a consumer can't pick a non-existent branch.
267
+ raw = _git_out(
268
+ ["for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes/origin"],
269
+ cwd=repo,
270
+ )
271
+ names: set[str] = set()
272
+ for line in raw.splitlines():
273
+ name = line.strip()
274
+ if not name or name.endswith("/HEAD") or name == "origin":
275
+ continue
276
+ names.add(name[len("origin/") :] if name.startswith("origin/") else name)
277
+ return sorted(names)
278
+
279
+
280
+ def _git_state(repo: str) -> dict:
281
+ # Real repo state for the Config Git tab (TASK-534) — local git only, so it
282
+ # answers even when gh/remote are down (the capability probe degrades alone).
283
+ return {
284
+ "branches": _branches(repo),
285
+ "current_branch": _git_out(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo),
286
+ "remote_url": _git_out(["config", "--get", "remote.origin.url"], cwd=repo),
287
+ }
288
+
289
+
290
+ def _emit(payload: dict, as_json: bool) -> None:
291
+ if as_json:
292
+ click.echo(json.dumps(payload, indent=2))
293
+ return
294
+ for key, value in payload.items():
295
+ click.echo(f"{key}: {value}")
296
+
297
+
298
+ # --------------------------------------------------------------------------- #
299
+ # worktree resolution
300
+ # --------------------------------------------------------------------------- #
301
+ def _claim_task() -> str | None:
302
+ try:
303
+ from cli.board_commands import _agent_session_id, _db_conn
304
+ from core.board_os.mcp_tools import cos_task_claim_next
305
+ except Exception:
306
+ return None
307
+ try:
308
+ conn = _db_conn()
309
+ env = json.loads(cos_task_claim_next(conn, agent_session=_agent_session_id()))
310
+ except Exception:
311
+ return None
312
+ claimed = (env.get("data") or {}).get("claimed") if env.get("ok") else None
313
+ return claimed.get("id") if claimed else None
314
+
315
+
316
+ def _branch_for(task_slug: str, session: str) -> str:
317
+ return f"agents/{task_slug}/{session}"
318
+
319
+
320
+ def _resolve_worktree(repo: str, task_slug: str, session: str) -> tuple[Path, str]:
321
+ # Find the worktree+branch `open` created, even when the session id differs
322
+ # across processes (the pid-<getpid> fallback gives a fresh value per process,
323
+ # TASK-541). Fast path: the session-derived path exists. Else scan this repo's
324
+ # worktree root for the task slug and read the real branch off the single match
325
+ # (the reaper derives it the same way); ambiguous/none falls back to the
326
+ # computed pair so the caller's existence check still surfaces a clear error.
327
+ root = _worktree_root(repo)
328
+ computed = root / f"{task_slug}-{session}"
329
+ if (computed / ".git").exists():
330
+ return computed, _branch_for(task_slug, session)
331
+ candidates = (
332
+ sorted(p for p in root.glob(f"{task_slug}-*") if (p / ".git").exists())
333
+ if root.exists()
334
+ else []
335
+ )
336
+ if len(candidates) == 1:
337
+ wt = candidates[0]
338
+ return wt, _git_out(["rev-parse", "--abbrev-ref", "HEAD"], cwd=wt) or _branch_for(
339
+ task_slug, session
340
+ )
341
+ return computed, _branch_for(task_slug, session)
342
+
343
+
344
+ def _resolve_repo(repo_opt: str | None) -> str:
345
+ repo = _toplevel(repo_opt or os.getcwd())
346
+ if repo is None:
347
+ raise click.ClickException(
348
+ "not inside a git repository — cos pr needs a git checkout (run 'git init' first)."
349
+ )
350
+ return repo
351
+
352
+
353
+ # --------------------------------------------------------------------------- #
354
+ # worktree dependency/secret bootstrap
355
+ # --------------------------------------------------------------------------- #
356
+ def _worktree_exclude(wt: Path) -> Path | None:
357
+ proc = _run(["git", "rev-parse", "--git-path", "info/exclude"], cwd=str(wt))
358
+ out = proc.stdout.strip()
359
+ if proc.returncode != 0 or not out:
360
+ return None
361
+ path = Path(out)
362
+ return path if path.is_absolute() else (wt / path)
363
+
364
+
365
+ def _exclude_in_worktree(exclude: Path | None, rel: str) -> None:
366
+ # A symlink named after a trailing-slash gitignore pattern (node_modules/) is
367
+ # NOT matched by that pattern and would leak into the PR — so root-anchor the
368
+ # linked path in the worktree's git exclude. Shared common-dir file: dedup, and
369
+ # the entries are already-gitignored names so polluting it is harmless.
370
+ if exclude is None:
371
+ return
372
+ entry = f"/{rel}"
373
+ try:
374
+ existing = exclude.read_text().splitlines() if exclude.exists() else []
375
+ if entry not in existing:
376
+ with exclude.open("a") as handle:
377
+ handle.write(f"{entry}\n")
378
+ except OSError as exc:
379
+ click.echo(f"cos pr: could not update worktree exclude for {rel}: {exc}", err=True)
380
+
381
+
382
+ def _run_setup(wt: Path, cmd: str) -> str:
383
+ # The consumer's one-time worktree setup (e.g. `npm ci`) — generous timeout, a
384
+ # real install legitimately exceeds the 120s gh/git default. Non-fatal: the
385
+ # worktree is usable, the agent just sees the warning at validate time.
386
+ try:
387
+ timeout = max(1, int(os.environ.get("COS_PR_SETUP_TIMEOUT", "600")))
388
+ except ValueError:
389
+ timeout = 600
390
+ proc = _run(["bash", "-lc", cmd], cwd=str(wt), timeout=timeout)
391
+ if proc.returncode != 0:
392
+ click.echo(
393
+ f"cos pr: worktree setup '{cmd}' failed (exit {proc.returncode}) — "
394
+ f"the validate command may fail until deps are installed.",
395
+ err=True,
396
+ )
397
+ return f"failed (exit {proc.returncode})"
398
+ return "ok"
399
+
400
+
401
+ def _bootstrap_worktree(repo: str, wt: Path) -> dict:
402
+ # A fresh worktree is a clean checkout with NO gitignored deps (node_modules,
403
+ # .venv, Pods) and NO local secrets (.env), so the agent's first validate
404
+ # command fails. Opt-in per project (git_settings): symlink the declared
405
+ # gitignored paths in from the main checkout and run a one-time setup command.
406
+ # No config → no-op, byte-identical to no bootstrap.
407
+ settings = _git_settings(repo)
408
+ includes = settings.get("worktree_include")
409
+ setup_cmd = settings.get("worktree_setup_cmd")
410
+ linked: list[str] = []
411
+ if isinstance(includes, list) and includes:
412
+ main_root = Path(_main_repo_root(repo))
413
+ exclude = _worktree_exclude(wt)
414
+ for raw in includes:
415
+ if not isinstance(raw, str) or not raw.strip():
416
+ continue
417
+ rel = raw.strip()
418
+ # Containment — never link a path outside the worktree. Only the project
419
+ # owner writes this config (the agent is blocked from hub-settings.json),
420
+ # so this just guards the owner's own typo, but cheaply.
421
+ if rel.startswith("/") or ".." in Path(rel).parts:
422
+ continue
423
+ src, dst = main_root / rel, wt / rel
424
+ if not src.exists() or os.path.lexists(dst):
425
+ continue
426
+ try:
427
+ dst.parent.mkdir(parents=True, exist_ok=True)
428
+ os.symlink(src, dst)
429
+ except OSError as exc:
430
+ click.echo(f"cos pr: could not link {rel}: {exc}", err=True)
431
+ continue
432
+ linked.append(rel)
433
+ _exclude_in_worktree(exclude, rel)
434
+ setup = (
435
+ _run_setup(wt, setup_cmd.strip())
436
+ if isinstance(setup_cmd, str) and setup_cmd.strip()
437
+ else None
438
+ )
439
+ return {"linked": linked, "setup": setup}
440
+
441
+
442
+ def _bootstrap_summary(bootstrap: dict) -> str:
443
+ parts = []
444
+ if bootstrap.get("linked"):
445
+ parts.append("linked=" + ",".join(bootstrap["linked"]))
446
+ if bootstrap.get("setup"):
447
+ parts.append("setup=" + bootstrap["setup"])
448
+ return " ".join(parts) or "(none)"
449
+
450
+
451
+ # --------------------------------------------------------------------------- #
452
+ # commands
453
+ # --------------------------------------------------------------------------- #
454
+ @click.group("pr", help="pr-mode multi-agent git executor (worktree → PR → CI → merge → cleanup).")
455
+ def pr_group() -> None:
456
+ pass
457
+
458
+
459
+ @pr_group.command("preflight", help="Check pr-mode capability (remote + gh + required CI).")
460
+ @click.option("--repo", "repo_opt", default=None, help="Repo path (default: cwd).")
461
+ @click.option(
462
+ "--integration",
463
+ default=None,
464
+ help="Integration branch (default: COS_GIT_INTEGRATION_BRANCH or main).",
465
+ )
466
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
467
+ def pr_preflight(repo_opt: str | None, integration: str | None, as_json: bool) -> None:
468
+ repo = _resolve_repo(repo_opt)
469
+ integration = integration or _integration_branch(repo)
470
+ cap = _preflight(repo, integration)
471
+ payload = {**cap, "mode": "pr" if cap["pr_ok"] else "degraded-trunk"}
472
+ if cap["unprotected_integration"]:
473
+ payload["warning"] = _unprotected_warning(integration)
474
+ _emit(payload, as_json)
475
+ sys.exit(0 if cap["pr_ok"] else 1)
476
+
477
+
478
+ @pr_group.command("open", help="Isolate work in a worktree + agents/* branch.")
479
+ @click.option(
480
+ "--task", "task_id", default=None, help="Board task id (else claim the next ready task)."
481
+ )
482
+ @click.option("--adhoc", is_flag=True, help="No board task — isolate ad-hoc code work.")
483
+ @click.option("--repo", "repo_opt", default=None)
484
+ @click.option("--integration", default=None)
485
+ @click.option("--json", "as_json", is_flag=True)
486
+ def pr_open(
487
+ task_id: str | None, adhoc: bool, repo_opt: str | None, integration: str | None, as_json: bool
488
+ ) -> None:
489
+ repo = _resolve_repo(repo_opt)
490
+ integration = integration or _integration_branch(repo)
491
+ session = _agent_session()
492
+
493
+ if adhoc:
494
+ task_slug, task_id = "adhoc", None
495
+ elif task_id:
496
+ task_slug = _sanitize(task_id)
497
+ else:
498
+ task_id = _claim_task()
499
+ if not task_id:
500
+ raise click.ClickException(
501
+ "no runnable task to claim — pass --task <id>, or --adhoc for no-task work."
502
+ )
503
+ task_slug = _sanitize(task_id)
504
+
505
+ branch = _branch_for(task_slug, session)
506
+ wt = _worktree_root(repo) / f"{task_slug}-{session}"
507
+ cap = _preflight(repo, integration)
508
+
509
+ if cap["remote"]:
510
+ _git(["fetch", "origin", integration], cwd=repo)
511
+
512
+ already = wt.exists() and (wt / ".git").exists()
513
+ if not already:
514
+ wt.parent.mkdir(parents=True, exist_ok=True)
515
+ base = f"origin/{integration}" if cap["remote"] else integration
516
+ add = _git(["worktree", "add", "-b", branch, str(wt), base], cwd=repo)
517
+ if add.returncode != 0:
518
+ # Branch already exists (idempotent re-open) — attach it instead.
519
+ attach = _git(["worktree", "add", str(wt), branch], cwd=repo)
520
+ if attach.returncode != 0:
521
+ raise click.ClickException(
522
+ f"worktree add failed:\n{add.stderr.strip()}\n{attach.stderr.strip()}"
523
+ )
524
+ # Shared objects/refs/packed-refs across worktrees → background gc during a
525
+ # peer's rebase is unsafe. Pin it off per worktree.
526
+ _git(["config", "gc.auto", "0"], cwd=wt)
527
+ # Lock the worktree so a peer's `git worktree prune` cannot remove a live
528
+ # session's checkout. On an idempotent re-open the tree is already locked and
529
+ # `git worktree lock` would no-op, stranding a previous (possibly dead) owner
530
+ # pid in the reason — unlock first so the stamp refreshes to THIS session's live
531
+ # pid (a peer reaper keeps a presence-live worktree regardless of the reason).
532
+ if already:
533
+ _git(["worktree", "unlock", str(wt)], cwd=repo)
534
+ _git(["worktree", "lock", str(wt), "--reason", _live_lock_reason(repo, session)], cwd=repo)
535
+
536
+ # Bootstrap deps/secrets only on a freshly created checkout.
537
+ bootstrap = _bootstrap_worktree(repo, wt) if not already else {"linked": [], "setup": None}
538
+
539
+ _emit(
540
+ {
541
+ "worktree": str(wt),
542
+ "branch": branch,
543
+ "task": task_id or "(adhoc)",
544
+ "integration": integration,
545
+ "project_root": repo,
546
+ "mode": "pr" if cap["pr_ok"] else "degraded-trunk",
547
+ "missing": ",".join(cap["missing"]) or "(none)",
548
+ "bootstrap": _bootstrap_summary(bootstrap),
549
+ "next": f"export COS_PROJECT_ROOT={repo} # then edit inside {wt}",
550
+ },
551
+ as_json,
552
+ )
553
+
554
+
555
+ @pr_group.command(
556
+ "submit", help="Publish: rebase onto FETCH_HEAD, lease-push, open PR, arm auto-merge."
557
+ )
558
+ @click.option("--task", "task_id", default=None)
559
+ @click.option("--adhoc", is_flag=True)
560
+ @click.option("--repo", "repo_opt", default=None)
561
+ @click.option("--integration", default=None)
562
+ @click.option("--title", default=None, help="PR title (default: branch name).")
563
+ @click.option("--body", default="", help="PR body.")
564
+ @click.option("--json", "as_json", is_flag=True)
565
+ def pr_submit(
566
+ task_id: str | None,
567
+ adhoc: bool,
568
+ repo_opt: str | None,
569
+ integration: str | None,
570
+ title: str | None,
571
+ body: str,
572
+ as_json: bool,
573
+ ) -> None:
574
+ repo = _resolve_repo(repo_opt)
575
+ integration = integration or _integration_branch(repo)
576
+ session = _agent_session()
577
+ task_slug = "adhoc" if adhoc else _sanitize(task_id) if task_id else None
578
+ if task_slug is None:
579
+ raise click.ClickException("cos pr submit needs --task <id> or --adhoc.")
580
+ wt, branch = _resolve_worktree(repo, task_slug, session)
581
+ if not (wt / ".git").exists():
582
+ raise click.ClickException(f"no open worktree at {wt} — run 'cos pr open' first.")
583
+
584
+ # `local` rung (TASK-540): commit-only, never push. Short-circuits before the
585
+ # capability probe so a repo with no remote is the intended mode, not a degrade.
586
+ autonomy = _autonomy_level(repo)
587
+ if autonomy == "local":
588
+ ahead = _commit_count(wt, f"{integration}..{branch}")
589
+ behind = _commit_count(wt, f"{branch}..{integration}")
590
+ if ahead == 0:
591
+ action = f"no commits to integrate yet — commit your work in {wt}, then re-run 'cos pr submit'."
592
+ else:
593
+ stale = (
594
+ f" branch is {behind} behind '{integration}' — rebase before integrating."
595
+ if behind
596
+ else ""
597
+ )
598
+ action = (
599
+ f"{ahead} commit(s) committed locally, not pushed (autonomy=local) — review with "
600
+ f"'git diff {integration}..{branch}', then a HUMAN integrates it in plain git "
601
+ f"OUTSIDE the agent (the agent is branch-guard-blocked from merging the shared "
602
+ f"checkout): 'git switch {integration} && git merge --no-ff {branch}'.{stale}"
603
+ )
604
+ _emit(
605
+ {
606
+ "branch": branch,
607
+ "pushed": False,
608
+ "autonomy_level": "local",
609
+ "merge_status": "local",
610
+ "commits_ahead": ahead,
611
+ "behind": behind,
612
+ "stale": behind > 0,
613
+ "action": action,
614
+ },
615
+ as_json,
616
+ )
617
+ return
618
+
619
+ cap = _preflight(repo, integration)
620
+ if not cap["pr_ok"]:
621
+ _emit(
622
+ {
623
+ "mode": "degraded-trunk",
624
+ "missing": ",".join(cap["missing"]),
625
+ "action": "pr-mode unavailable — commit on the worktree and integrate via the trunk path",
626
+ },
627
+ as_json,
628
+ )
629
+ sys.exit(1)
630
+
631
+ # Circuit-breaker BEFORE any push — refuse past the per-session open-PR cap
632
+ # so a red / quota-dead CI (TASK-513) can't grow open PRs without bound, and
633
+ # a capped submit never orphans a pushed branch with no PR (§8, findings 7/9).
634
+ cap_max = _env_int("COS_PR_MAX_OPEN", 5)
635
+ # Count against the resolved branch's session, not the process session — under
636
+ # session-id drift (_resolve_worktree) `branch` carries the original session
637
+ # while `session` is a fresh pid-<getpid>; counting the latter reads 0 and
638
+ # bypasses the cap on exactly the branch being pushed (review finding 1).
639
+ open_prs = _open_pr_count(repo, branch.rsplit("/", 1)[-1])
640
+ # open_prs < 0 = could not determine (gh down / quota-dead) — fail SAFE and
641
+ # refuse the push rather than count it as "0 open PRs" (M1).
642
+ unknown = open_prs < 0
643
+ if unknown or open_prs >= cap_max:
644
+ _emit(
645
+ {
646
+ "branch": branch,
647
+ "pushed": False,
648
+ "circuit_breaker": "open",
649
+ "open_prs": "unknown" if unknown else open_prs,
650
+ "cap": cap_max,
651
+ "action": (
652
+ "open-PR count unknown (gh down/quota) — not pushing; restore gh, then retry"
653
+ if unknown
654
+ else "open-PR cap reached — not pushing; drain existing PRs first"
655
+ ),
656
+ },
657
+ as_json,
658
+ )
659
+ sys.exit(1)
660
+
661
+ # Rebase onto the PINNED fetched ref (FETCH_HEAD), never the shared moving
662
+ # branch — branch-guard permits this because the op is worktree-scoped (§5).
663
+ _git(["fetch", "origin", integration], cwd=wt)
664
+ rebase = _git(["rebase", "FETCH_HEAD"], cwd=wt)
665
+ if rebase.returncode != 0:
666
+ _git(["rebase", "--abort"], cwd=wt)
667
+ raise click.ClickException(
668
+ f"rebase onto origin/{integration} conflicted — resolve in the worktree, then retry."
669
+ )
670
+
671
+ # sha-pinned lease: refresh origin/<branch> first so the lease pins to its
672
+ # TRUE current remote sha (no-op on a first push); empty lease for a first
673
+ # push. With --force-if-includes this never clobbers a concurrent push.
674
+ _git(["fetch", "origin", branch], cwd=wt)
675
+ remote_sha = _git_out(["rev-parse", f"origin/{branch}"], cwd=wt)
676
+ lease = (
677
+ f"--force-with-lease={branch}:{remote_sha}"
678
+ if remote_sha
679
+ else f"--force-with-lease={branch}"
680
+ )
681
+ push = _git(["push", lease, "--force-if-includes", "-u", "origin", branch], cwd=wt)
682
+ if push.returncode != 0:
683
+ raise click.ClickException(f"push rejected (lease/connectivity):\n{push.stderr.strip()}")
684
+
685
+ pr = _run(
686
+ [
687
+ "gh",
688
+ "pr",
689
+ "create",
690
+ "--base",
691
+ integration,
692
+ "--head",
693
+ branch,
694
+ "--title",
695
+ title or branch,
696
+ "--body",
697
+ body or f"agent branch {branch}",
698
+ ],
699
+ cwd=wt,
700
+ )
701
+ pr_ok = pr.returncode == 0
702
+ arm_allowed = autonomy in ("auto_merge", "autonomous")
703
+ armed = False
704
+ if pr_ok and arm_allowed and cap["required_check"]:
705
+ # Auto-merge ONLY when a required check exists, else the PR merges with
706
+ # no CI gate. Stays armed; merges itself once the check is green.
707
+ armed = _run(["gh", "pr", "merge", "--auto", "--squash"], cwd=wt).returncode == 0
708
+
709
+ # auto_merge + a required REVIEW (CODEOWNERS / ruleset) = armed but unmergeable
710
+ # until a human approves — surface it so submit never reports "will merge" while
711
+ # the PR silently waits on an approval the agent can't give.
712
+ review_required = armed and _pr_review_required(wt, branch)
713
+
714
+ # A no-required-check repo silently no-ops `gh pr merge --auto`; surface the
715
+ # outcome so submit never strands an open PR with no signal.
716
+ if armed and review_required:
717
+ merge_status = "auto-merge-armed-awaiting-review"
718
+ action = (
719
+ f"PR auto-merge armed, but '{integration}' requires an approving review — it "
720
+ f"stays open until a human approves, then merges itself. Approve the PR (the "
721
+ f"agent never self-approves)."
722
+ )
723
+ elif armed:
724
+ merge_status = "auto-merge-armed"
725
+ action = f"PR merges itself once the required check on '{integration}' is green"
726
+ elif not pr_ok:
727
+ merge_status = "pr-create-failed"
728
+ action = pr.stderr.strip() or "gh pr create failed — PR not opened; branch is pushed"
729
+ elif not arm_allowed:
730
+ # draft autonomy: the PR is intentionally human-merged, regardless of CI.
731
+ merge_status = "draft"
732
+ action = (
733
+ f"PR open in '{autonomy}' autonomy — a human merges it. Set "
734
+ f"autonomy_level=auto_merge in Hub Config→Git to arm auto-merge."
735
+ )
736
+ elif not cap["required_check"]:
737
+ merge_status = "degraded-no-required-check"
738
+ action = (
739
+ f"PR open but auto-merge NOT armed: no required status check on "
740
+ f"'{integration}'. Add a required check (pr-workflow.md §11) and re-run "
741
+ f"'cos pr submit', or merge the PR manually."
742
+ )
743
+ else:
744
+ merge_status = "arm-failed"
745
+ action = (
746
+ "required check exists but 'gh pr merge --auto' did not arm — check gh auth/permissions"
747
+ )
748
+
749
+ # H3: auto_merge/autonomous + no required check = a silent deadlock (the PR will
750
+ # neither merge nor fail). Escalate the board task to blocked so a human adds the
751
+ # check, instead of leaving an open PR with only a non-fatal stderr line.
752
+ board_blocked = False
753
+ if merge_status == "degraded-no-required-check" and task_id:
754
+ board_blocked = _escalate_blocked(
755
+ repo,
756
+ task_id,
757
+ f"pr-mode auto-merge deadlock: autonomy={autonomy} but '{integration}' has no "
758
+ f"required status check — the PR will neither merge nor fail",
759
+ f"pr-mode auto-merge deadlock (no required check on '{integration}')",
760
+ )
761
+ if board_blocked:
762
+ action += " Task escalated to blocked — add a required check, then re-submit."
763
+
764
+ payload = {
765
+ "branch": branch,
766
+ "pushed": True,
767
+ "pr_created": pr_ok,
768
+ "pr_url": pr.stdout.strip() if pr_ok else "",
769
+ "auto_merge_armed": armed,
770
+ "required_check": cap["required_check"],
771
+ "review_required": review_required,
772
+ "autonomy_level": autonomy,
773
+ "merge_status": merge_status,
774
+ "board_blocked": board_blocked,
775
+ "action": action,
776
+ }
777
+ if cap["unprotected_integration"]:
778
+ payload["warning"] = _unprotected_warning(integration)
779
+ _emit(payload, as_json)
780
+
781
+
782
+ def _land_verify_ok(repo: str) -> bool:
783
+ # local_autonomous lands only after a GREEN local verify — read the same
784
+ # .last-verify.json freshness marker the DoD gate uses (most-recent PASS within
785
+ # the window). Absent / only-FAIL / stale → refuse to land (TASK-614).
786
+ path = Path(repo) / ".coding-os" / ".last-verify.json"
787
+ try:
788
+ data = json.loads(path.read_text(encoding="utf-8"))
789
+ except (OSError, json.JSONDecodeError):
790
+ return False
791
+ if not isinstance(data, dict):
792
+ return False
793
+ ttl = _env_int("COS_PR_LAND_VERIFY_TTL", 1800)
794
+ now = int(time.time())
795
+ for suite in data.values():
796
+ if isinstance(suite, dict) and suite.get("status") == "PASS":
797
+ ts = suite.get("ts")
798
+ if isinstance(ts, int) and 0 <= now - ts <= ttl:
799
+ return True
800
+ return False
801
+
802
+
803
+ @pr_group.command(
804
+ "land",
805
+ help="local_autonomous: merge the agent branch onto LOCAL integration after a green "
806
+ "verify, then clean up (zero push/PR/CI).",
807
+ )
808
+ @click.option("--task", "task_id", default=None)
809
+ @click.option("--adhoc", is_flag=True)
810
+ @click.option("--repo", "repo_opt", default=None)
811
+ @click.option("--integration", default=None)
812
+ @click.option(
813
+ "--no-ff/--ff",
814
+ "no_ff",
815
+ default=True,
816
+ help="--no-ff (default) keeps a merge commit; --ff for fast-forward only.",
817
+ )
818
+ @click.option("--json", "as_json", is_flag=True)
819
+ def pr_land(
820
+ task_id: str | None,
821
+ adhoc: bool,
822
+ repo_opt: str | None,
823
+ integration: str | None,
824
+ no_ff: bool,
825
+ as_json: bool,
826
+ ) -> None:
827
+ repo = _resolve_repo(repo_opt)
828
+ integration = integration or _integration_branch(repo)
829
+ session = _agent_session()
830
+ task_slug = "adhoc" if adhoc else _sanitize(task_id) if task_id else None
831
+ if task_slug is None:
832
+ raise click.ClickException("cos pr land needs --task <id> or --adhoc.")
833
+ wt, branch = _resolve_worktree(repo, task_slug, session)
834
+ if not (wt / ".git").exists():
835
+ raise click.ClickException(f"no open worktree at {wt} — run 'cos pr open' first.")
836
+
837
+ # A RED/absent local verify must NOT land — the rung's whole premise is "green first".
838
+ if not _land_verify_ok(repo):
839
+ _emit(
840
+ {
841
+ "branch": branch,
842
+ "landed": False,
843
+ "reason": "verify-not-green",
844
+ "action": "no recent green verify — run the matrix verify in the worktree, then re-run 'cos pr land'.",
845
+ },
846
+ as_json,
847
+ )
848
+ sys.exit(1)
849
+
850
+ ahead = _commit_count(wt, f"{integration}..{branch}")
851
+ if ahead == 0:
852
+ _emit(
853
+ {
854
+ "branch": branch,
855
+ "landed": False,
856
+ "reason": "nothing-to-land",
857
+ "action": f"no commits ahead of '{integration}' — commit your work in the worktree first.",
858
+ },
859
+ as_json,
860
+ )
861
+ return
862
+
863
+ # Sanctioned land: merge onto LOCAL integration on the SHARED checkout. cos exports
864
+ # COS_PR_LAND so branch-guard recognises this path (an agent's raw `git merge` on the
865
+ # shared tree stays BLOCKED). Zero network — no push, no PR, no CI.
866
+ merge_args = (
867
+ ["merge", "--no-ff", branch, "-m", f"land {branch} (local_autonomous)"]
868
+ if no_ff
869
+ else ["merge", "--ff-only", branch]
870
+ )
871
+ os.environ["COS_PR_LAND"] = "1"
872
+ try:
873
+ merged = _git(merge_args, cwd=repo)
874
+ if merged.returncode != 0:
875
+ _git(["merge", "--abort"], cwd=repo)
876
+ _emit(
877
+ {
878
+ "branch": branch,
879
+ "landed": False,
880
+ "reason": "merge-conflict",
881
+ "action": f"merge of '{branch}' onto '{integration}' conflicted — aborted; "
882
+ f"rebase the worktree onto '{integration}', re-verify, then retry 'cos pr land'.",
883
+ },
884
+ as_json,
885
+ )
886
+ sys.exit(1)
887
+ # Landed: the work is on integration, so the worktree+branch are safe to GC (no
888
+ # orphan). Unlock first — `pr open` locks the worktree with the owner stamp, and
889
+ # `worktree remove` refuses a locked tree (same as the reaper's _reap_one).
890
+ _git(["worktree", "unlock", str(wt)], cwd=repo)
891
+ _git(["worktree", "remove", "--force", str(wt)], cwd=repo)
892
+ _git(["branch", "-D", branch], cwd=repo)
893
+ _git(["worktree", "prune"], cwd=repo)
894
+ finally:
895
+ os.environ.pop("COS_PR_LAND", None)
896
+
897
+ _emit(
898
+ {
899
+ "branch": branch,
900
+ "landed": True,
901
+ "integration": integration,
902
+ "commits": ahead,
903
+ "action": f"merged {ahead} commit(s) onto local '{integration}'; worktree+branch removed (zero network).",
904
+ },
905
+ as_json,
906
+ )
907
+
908
+
909
+ @pr_group.command("status", help="List this repo's pr-mode worktrees, branches, and open PRs.")
910
+ @click.option("--repo", "repo_opt", default=None)
911
+ @click.option(
912
+ "--branch",
913
+ default=None,
914
+ help="Report one agent branch's CI rollup (merged|red|pending|review-required|passing|passing-unarmed|closed|none) — the driver-loop signal.",
915
+ )
916
+ @click.option("--json", "as_json", is_flag=True)
917
+ def pr_status(repo_opt: str | None, branch: str | None, as_json: bool) -> None:
918
+ repo = _resolve_repo(repo_opt)
919
+ if branch:
920
+ # Single-branch CI signal the pr-mode-driver skill branches on (TASK-529).
921
+ _emit({"branch": branch, "ci_rollup": _pr_ci_rollup(repo, branch)}, as_json)
922
+ return
923
+ wt_root = _worktree_root(repo)
924
+ worktrees = []
925
+ if wt_root.is_dir():
926
+ worktrees = sorted(p.name for p in wt_root.iterdir() if p.is_dir())
927
+ branches = [
928
+ b.strip().lstrip("* ").strip()
929
+ for b in _git_out(["branch", "--list", "agents/*"], cwd=repo).splitlines()
930
+ if b.strip()
931
+ ]
932
+ pr_rows: list[dict] = []
933
+ if _gh_ready():
934
+ out = _run(
935
+ [
936
+ "gh",
937
+ "pr",
938
+ "list",
939
+ "--search",
940
+ "head:agents/",
941
+ "--json",
942
+ "number,headRefName,state,mergedAt,statusCheckRollup,isDraft,autoMergeRequest,reviewDecision",
943
+ ],
944
+ cwd=repo,
945
+ )
946
+ if out.returncode == 0:
947
+ try:
948
+ pr_rows = json.loads(out.stdout or "[]")
949
+ except json.JSONDecodeError:
950
+ pr_rows = []
951
+ open_prs = ",".join(f"#{r.get('number')}:{r.get('headRefName')}" for r in pr_rows)
952
+ ci_rollup = ",".join(f"{r.get('headRefName')}={_rollup_state(r)}" for r in pr_rows)
953
+ _emit(
954
+ {
955
+ "worktree_root": str(wt_root),
956
+ "worktrees": ",".join(worktrees) or "(none)",
957
+ "agent_branches": ",".join(branches) or "(none)",
958
+ "open_prs": open_prs or "(none/gh unavailable)",
959
+ "ci_rollup": ci_rollup or "(none)",
960
+ },
961
+ as_json,
962
+ )
963
+
964
+
965
+ def _agent_worktrees(repo: str) -> dict[str, Path]:
966
+ # Map each live agents/* branch → its worktree path from `git worktree list`.
967
+ # Only branches with a worktree appear, so this naturally scopes to the
968
+ # currently-checked-out (i.e. concurrently-active) agents.
969
+ result: dict[str, Path] = {}
970
+ cur: str | None = None
971
+ for line in _git_out(["worktree", "list", "--porcelain"], cwd=repo).splitlines():
972
+ if line.startswith("worktree "):
973
+ cur = line[len("worktree ") :].strip()
974
+ elif line.startswith("branch ") and cur:
975
+ name = _unqualify_head(line[len("branch ") :].strip())
976
+ if name.startswith("agents/"):
977
+ result[name] = Path(cur)
978
+ return result
979
+
980
+
981
+ def _unqualify_head(ref: str) -> str:
982
+ return ref[len("refs/heads/") :] if ref.startswith("refs/heads/") else ref
983
+
984
+
985
+ def _changed_files(repo: str, branch: str, integration: str, wt: Path | None) -> set[str]:
986
+ # The branch's "touched files" = committed diff since it forked the integration
987
+ # line (merge-base, so a moving integration head doesn't distort it) UNION the
988
+ # worktree's still-uncommitted paths (earliest possible pre-detection signal).
989
+ files: set[str] = set()
990
+ base = _git_out(["merge-base", integration, branch], cwd=repo) or integration
991
+ for line in _git_out(["diff", "--name-only", f"{base}..{branch}"], cwd=repo).splitlines():
992
+ if line.strip():
993
+ files.add(line.strip())
994
+ if wt is not None and (wt / ".git").exists():
995
+ for line in _git_out(["status", "--porcelain"], cwd=wt).splitlines():
996
+ path = line[3:].strip()
997
+ if " -> " in path: # rename entry: 'old -> new' — the new path is what's edited
998
+ path = path.split(" -> ", 1)[1].strip()
999
+ path = path.strip('"') # porcelain quotes paths containing special chars
1000
+ if path:
1001
+ files.add(path)
1002
+ return files
1003
+
1004
+
1005
+ @pr_group.command(
1006
+ "triage",
1007
+ help="Ranked digest of all open agents/* PRs (ci + review + conflict + age) — review the highest-value, lowest-risk first.",
1008
+ )
1009
+ @click.option("--repo", "repo_opt", default=None)
1010
+ @click.option("--json", "as_json", is_flag=True)
1011
+ def pr_triage(repo_opt: str | None, as_json: bool) -> None:
1012
+ repo = _resolve_repo(repo_opt)
1013
+ if not _gh_ready():
1014
+ _emit(
1015
+ {
1016
+ "open": 0,
1017
+ "quick_merge": 0,
1018
+ "prs": [],
1019
+ "action": "gh unavailable — cannot triage open PRs",
1020
+ },
1021
+ as_json,
1022
+ )
1023
+ return
1024
+ out = _run(
1025
+ [
1026
+ "gh",
1027
+ "pr",
1028
+ "list",
1029
+ "--state",
1030
+ "open",
1031
+ "--search",
1032
+ "head:agents/",
1033
+ "--json",
1034
+ "number,headRefName,state,mergedAt,statusCheckRollup,isDraft,autoMergeRequest,"
1035
+ "reviewDecision,mergeable,createdAt",
1036
+ "--limit",
1037
+ "100",
1038
+ ],
1039
+ cwd=repo,
1040
+ )
1041
+ rows: list[dict] = []
1042
+ if out.returncode == 0:
1043
+ try:
1044
+ rows = json.loads(out.stdout or "[]")
1045
+ except json.JSONDecodeError:
1046
+ rows = []
1047
+ agent_rows = [r for r in rows if str(r.get("headRefName", "")).startswith("agents/")]
1048
+ entries = sorted(
1049
+ (_triage_entry(r) for r in agent_rows), key=lambda e: (e["rank"], e["created_at"])
1050
+ )
1051
+ quick = sum(1 for e in entries if e["category"] == "quick-merge")
1052
+ if not entries:
1053
+ _emit(
1054
+ {
1055
+ "open": 0,
1056
+ "quick_merge": 0,
1057
+ "prs": [],
1058
+ "action": "no open agent PRs — nothing to triage",
1059
+ },
1060
+ as_json,
1061
+ )
1062
+ return
1063
+ if as_json:
1064
+ _emit(
1065
+ {
1066
+ "open": len(entries),
1067
+ "quick_merge": quick,
1068
+ "prs": entries,
1069
+ "action": "review in listed order; quick-merge rows are green + conflict-free + no required review",
1070
+ },
1071
+ as_json,
1072
+ )
1073
+ return
1074
+ lines = [f"{len(entries)} open agent PR(s) — review in this order ({quick} safe quick-merge):"]
1075
+ for e in entries:
1076
+ flag = " ✅ quick-merge" if e["category"] == "quick-merge" else ""
1077
+ lines.append(
1078
+ f" #{e['number']} {e['branch']} — {e['category']} "
1079
+ f"(ci={e['ci_rollup']}, review_required={e['review_required']}, conflict={e['conflict']}){flag}"
1080
+ )
1081
+ lines.append(
1082
+ "Tip: to leave the hot path entirely, set autonomy_level=auto_merge with a required check "
1083
+ "(docs/playbooks/pr-mode-ci-economics.md)."
1084
+ )
1085
+ click.echo("\n".join(lines))
1086
+
1087
+
1088
+ @pr_group.command(
1089
+ "conflicts",
1090
+ help="Advisory: which live peer agent branch also edits your files (early-warning before a land-time conflict).",
1091
+ )
1092
+ @click.option(
1093
+ "--branch", default=None, help="Target agent branch (default: the current worktree's HEAD)."
1094
+ )
1095
+ @click.option("--repo", "repo_opt", default=None)
1096
+ @click.option("--json", "as_json", is_flag=True)
1097
+ def pr_conflicts(branch: str | None, repo_opt: str | None, as_json: bool) -> None:
1098
+ repo = _resolve_repo(repo_opt)
1099
+ integration = _integration_branch(repo)
1100
+ worktrees = _agent_worktrees(repo)
1101
+ target = branch or _git_out(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo)
1102
+ if not target.startswith("agents/"):
1103
+ raise click.ClickException(
1104
+ "not on an agents/* branch — pass --branch <agents/...> or run from inside an agent worktree."
1105
+ )
1106
+ target_files = _changed_files(repo, target, integration, worktrees.get(target))
1107
+ overlaps: list[dict] = []
1108
+ for peer, peer_wt in sorted(worktrees.items()):
1109
+ if peer == target:
1110
+ continue
1111
+ shared = sorted(target_files & _changed_files(repo, peer, integration, peer_wt))
1112
+ if shared:
1113
+ overlaps.append({"branch": peer, "files": shared})
1114
+ # Advisory ONLY — overlap is a heads-up, never a block: two agents may legitimately
1115
+ # touch one file in different places; the rebase-at-submit + merge queue catch a
1116
+ # real conflict at land. Always exit 0.
1117
+ _emit(
1118
+ {
1119
+ "branch": target,
1120
+ "changed_files": len(target_files),
1121
+ "conflicts": overlaps
1122
+ if as_json
1123
+ else ("; ".join(f"{o['branch']}={','.join(o['files'])}" for o in overlaps) or "(none)"),
1124
+ "advisory": (
1125
+ "peer overlap — coordinate or expect a rebase at land"
1126
+ if overlaps
1127
+ else "no peer overlap"
1128
+ ),
1129
+ },
1130
+ as_json,
1131
+ )
1132
+
1133
+
1134
+ def _pr_state(repo: str, branch: str) -> str:
1135
+ # "merged" | "closed" | "open" | "none" | "unknown" — drives the cleanup
1136
+ # merge-gate so an open PR's worktree isn't destroyed mid-flight (TASK-530).
1137
+ if not _gh_ready():
1138
+ return "unknown"
1139
+ listing = _run(
1140
+ ["gh", "pr", "list", "--head", branch, "--state", "all", "--json", "state,mergedAt"],
1141
+ cwd=repo,
1142
+ )
1143
+ if listing.returncode != 0:
1144
+ return "unknown"
1145
+ try:
1146
+ prs = json.loads(listing.stdout or "[]")
1147
+ except json.JSONDecodeError:
1148
+ return "unknown"
1149
+ if not prs:
1150
+ return "none"
1151
+ if prs[0].get("mergedAt"):
1152
+ return "merged"
1153
+ return str(prs[0].get("state", "")).lower() or "unknown"
1154
+
1155
+
1156
+ def _rollup_state(pr: dict) -> str:
1157
+ # merged|red|queued|pending|review-required|passing|passing-unarmed|closed|none — one
1158
+ # CI signal distilled from gh's statusCheckRollup (+ a GraphQL mergeQueueEntry probe
1159
+ # injected by _pr_ci_rollup) for the autonomous driver loop.
1160
+ if pr.get("mergedAt") or str(pr.get("state", "")).upper() == "MERGED":
1161
+ return "merged"
1162
+ if str(pr.get("state", "")).upper() == "CLOSED":
1163
+ return "closed"
1164
+ checks = pr.get("statusCheckRollup") or []
1165
+ bad = {"FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"}
1166
+ waiting = {"IN_PROGRESS", "QUEUED", "PENDING", "WAITING", "REQUESTED", "EXPECTED"}
1167
+
1168
+ def fields(
1169
+ check: dict,
1170
+ ) -> set[str]: # CheckRun uses conclusion/status; StatusContext uses state
1171
+ return {str(check.get(k) or "").upper() for k in ("conclusion", "status", "state")}
1172
+
1173
+ if any(bad & fields(c) for c in checks):
1174
+ return "red"
1175
+ # Merge-queue membership (GraphQL mergeQueueEntry; gh pr view --json cannot supply it,
1176
+ # so _pr_ci_rollup injects it). A queued PR's merge_group checks read as `waiting`
1177
+ # below, so this MUST precede the waiting/no-checks arms — the driver waits on the
1178
+ # queue, never re-submits. UNMERGEABLE = the queue will eject it → red, so only this
1179
+ # PR is healed while followers keep merging. Absent entry → byte-unchanged (no queue).
1180
+ mq_state = str((pr.get("mergeQueueEntry") or {}).get("state") or "").upper()
1181
+ if mq_state == "UNMERGEABLE":
1182
+ return "red"
1183
+ if mq_state: # QUEUED | AWAITING_CHECKS | MERGEABLE | LOCKED — in the queue, just wait
1184
+ return "queued"
1185
+ if not checks:
1186
+ return "pending"
1187
+ if any(waiting & fields(c) for c in checks):
1188
+ return "pending"
1189
+ # Green checks — but a required review (branch protection / ruleset / CODEOWNERS)
1190
+ # still blocks merge until a human approves. Distinct from passing-unarmed so the
1191
+ # driver STOPs for an approval instead of spinning on an auto-merge that has been
1192
+ # armed but can never fire while the review gate is open.
1193
+ if str(pr.get("reviewDecision") or "").upper() in {"REVIEW_REQUIRED", "CHANGES_REQUESTED"}:
1194
+ return "review-required"
1195
+ # Green — but only "passing" (auto-merge will land it) when auto-merge is armed
1196
+ # AND the PR isn't a draft; else "passing-unarmed" so the driver STOPs for a human
1197
+ # merge from the signal alone, never from a remembered submit merge_status.
1198
+ if pr.get("isDraft") or not pr.get("autoMergeRequest"):
1199
+ return "passing-unarmed"
1200
+ return "passing"
1201
+
1202
+
1203
+ def _pr_ci_rollup(repo: str, branch: str) -> str:
1204
+ if not _gh_ready():
1205
+ return "unknown"
1206
+ out = _run(
1207
+ [
1208
+ "gh",
1209
+ "pr",
1210
+ "view",
1211
+ branch,
1212
+ "--json",
1213
+ "number,state,mergedAt,statusCheckRollup,isDraft,autoMergeRequest,reviewDecision",
1214
+ ],
1215
+ cwd=repo,
1216
+ )
1217
+ if out.returncode != 0:
1218
+ return "none" # no PR for this branch (or gh error) → driver opens/submits
1219
+ try:
1220
+ pr = json.loads(out.stdout or "{}")
1221
+ except json.JSONDecodeError:
1222
+ return "unknown"
1223
+ if not pr:
1224
+ return "none"
1225
+ state = _rollup_state(pr)
1226
+ # A PR only enters the merge queue once green/running, so probe mergeQueueEntry only
1227
+ # for the non-final verdicts — a merged/closed/red/review-required result is already
1228
+ # authoritative, and skipping the probe there means a repo with NO merge queue makes
1229
+ # zero extra calls for them (byte-unchanged). _merge_queue_entry fails open to {}.
1230
+ if state in {"pending", "passing", "passing-unarmed"}:
1231
+ entry = _merge_queue_entry(repo, pr.get("number"))
1232
+ if entry:
1233
+ pr["mergeQueueEntry"] = entry
1234
+ return _rollup_state(pr)
1235
+ return state
1236
+
1237
+
1238
+ def _merge_queue_entry(repo: str, number: int | None) -> dict:
1239
+ # gh pr view --json has no mergeQueueEntry field (gh 2.95), so read it via GraphQL —
1240
+ # gh resolves {owner}/{repo} from the repo's remote. Returns {} (not queued / no queue
1241
+ # configured / gh error) so _rollup_state stays byte-unchanged where no queue exists.
1242
+ if not number or not _gh_ready():
1243
+ return {}
1244
+ out = _run(
1245
+ [
1246
+ "gh",
1247
+ "api",
1248
+ "graphql",
1249
+ "-F",
1250
+ "owner={owner}",
1251
+ "-F",
1252
+ "name={repo}",
1253
+ "-F",
1254
+ f"number={int(number)}",
1255
+ "-f",
1256
+ "query=query($owner:String!,$name:String!,$number:Int!){"
1257
+ "repository(owner:$owner,name:$name){pullRequest(number:$number){"
1258
+ "mergeQueueEntry{state position}}}}",
1259
+ ],
1260
+ cwd=repo,
1261
+ )
1262
+ if out.returncode != 0:
1263
+ return {}
1264
+ try:
1265
+ data = json.loads(out.stdout or "{}")
1266
+ except json.JSONDecodeError:
1267
+ return {}
1268
+ pull = ((data.get("data") or {}).get("repository") or {}).get("pullRequest") or {}
1269
+ return pull.get("mergeQueueEntry") or {}
1270
+
1271
+
1272
+ def _triage_entry(pr: dict) -> dict:
1273
+ # One ranked triage row for an open agents/* PR. Rank orders the human's
1274
+ # review queue to minimise time-to-unblock: safe quick-merges first (just
1275
+ # click merge), then the ones needing a real review, then conflict/red (need
1276
+ # work), then CI still running (no human action yet). created_at breaks ties
1277
+ # oldest-first so the backlog drains FIFO.
1278
+ rollup = _rollup_state(pr) # already folds a blocking reviewDecision into "review-required"
1279
+ conflict = str(pr.get("mergeable") or "").upper() == "CONFLICTING"
1280
+ review_required = rollup == "review-required"
1281
+ green = rollup in ("passing", "passing-unarmed")
1282
+ if green and not conflict:
1283
+ category, rank = "quick-merge", 0 # green + clean + no required review → safe one-click
1284
+ elif review_required and not conflict:
1285
+ category, rank = "needs-review", 1
1286
+ elif conflict:
1287
+ category, rank = "conflict", 2 # needs a rebase before it can land
1288
+ elif rollup == "red":
1289
+ category, rank = "red", 3
1290
+ elif rollup in ("pending", "queued"):
1291
+ category, rank = "waiting", 4
1292
+ else:
1293
+ category, rank = rollup, 5
1294
+ return {
1295
+ "rank": rank,
1296
+ "category": category,
1297
+ "branch": pr.get("headRefName", ""),
1298
+ "number": pr.get("number"),
1299
+ "ci_rollup": rollup,
1300
+ "review_required": review_required,
1301
+ "conflict": conflict,
1302
+ "created_at": pr.get("createdAt", ""),
1303
+ }
1304
+
1305
+
1306
+ def _pr_review_required(wt: Path, branch: str) -> bool:
1307
+ # Does THIS PR's review gate (branch protection / ruleset / CODEOWNERS) still
1308
+ # block merge? Read the PR's own reviewDecision — authoritative where a probe of
1309
+ # required_pull_request_reviews would miss ruleset- and CODEOWNERS-driven reviews.
1310
+ out = _run(["gh", "pr", "view", branch, "--json", "reviewDecision"], cwd=wt)
1311
+ if out.returncode != 0:
1312
+ return False
1313
+ try:
1314
+ decision = json.loads(out.stdout or "{}").get("reviewDecision")
1315
+ except json.JSONDecodeError:
1316
+ return False
1317
+ return str(decision or "").upper() in {"REVIEW_REQUIRED", "CHANGES_REQUESTED"}
1318
+
1319
+
1320
+ def _branch_recoverable(repo: str, branch: str, integration: str) -> bool:
1321
+ # gh-independent cleanup safety net: True when every branch commit is already
1322
+ # reachable from an origin ref (or the local integration), so deleting the
1323
+ # local branch loses nothing (TASK-530).
1324
+ if not _git_out(["rev-parse", "--verify", branch], cwd=repo):
1325
+ return True
1326
+ if _git(["merge-base", "--is-ancestor", branch, integration], cwd=repo).returncode == 0:
1327
+ return True
1328
+ for ref in (f"origin/{branch}", f"origin/{integration}"):
1329
+ if _git(["merge-base", "--is-ancestor", branch, ref], cwd=repo).returncode == 0:
1330
+ return True
1331
+ return False
1332
+
1333
+
1334
+ def _preserve_reaped(repo: str, wt: Path, branch: str) -> str | None:
1335
+ # gh-independent, offline-safe preservation before a reap destroys anything
1336
+ # (TASK-535). Commit any uncommitted/untracked work onto the (doomed) branch —
1337
+ # the worktree + branch are about to be GC'd, so mutating them is free, and
1338
+ # `--no-verify` guarantees the capture can't be blocked by a consumer hook
1339
+ # (a plain `git stash create` would silently drop untracked files, which is
1340
+ # exactly the new files an agent creates). Then bundle the branch tip into a
1341
+ # quarantine dir. Returns the bundle path, or None when the work could not be
1342
+ # safely captured (commit or bundle failed) — the caller then keeps the worktree.
1343
+ if _git_out(["status", "--porcelain"], cwd=wt):
1344
+ _git(["add", "-A"], cwd=wt)
1345
+ # Inject a fallback identity so an un-configured worktree (no user.email/name)
1346
+ # still commits — else the dirty work never reaches the branch and the bundle
1347
+ # below would silently capture only the old tip (D2). Bail on any other commit
1348
+ # failure too, so the caller never treats unpreserved work as safe.
1349
+ commit = _git(
1350
+ [
1351
+ "-c",
1352
+ "user.email=reaper@coding-os",
1353
+ "-c",
1354
+ "user.name=cos-reaper",
1355
+ "commit",
1356
+ "-q",
1357
+ "--no-verify",
1358
+ "-m",
1359
+ f"chore: preserve reaped agent work ({branch})",
1360
+ ],
1361
+ cwd=wt,
1362
+ )
1363
+ if commit.returncode != 0:
1364
+ return None
1365
+ base = os.environ.get("COS_REAPED_ROOT") or str(Path.home() / ".coding-os" / "reaped")
1366
+ qdir = Path(base) / _repo_slug(repo)
1367
+ qdir.mkdir(parents=True, exist_ok=True)
1368
+ bundle = qdir / f"{_sanitize(branch)}-{int(time.time())}.bundle"
1369
+ ok = _git(["bundle", "create", str(bundle), branch], cwd=repo).returncode == 0
1370
+ return str(bundle) if ok else None
1371
+
1372
+
1373
+ @pr_group.command(
1374
+ "cleanup",
1375
+ help="Remove the worktree + delete the branch + prune (merge-gated; --force to override).",
1376
+ )
1377
+ @click.option("--task", "task_id", default=None)
1378
+ @click.option("--adhoc", is_flag=True)
1379
+ @click.option("--repo", "repo_opt", default=None)
1380
+ @click.option(
1381
+ "--force",
1382
+ is_flag=True,
1383
+ help="Remove even if the PR is open / the branch is unpushed (human override).",
1384
+ )
1385
+ @click.option("--json", "as_json", is_flag=True)
1386
+ def pr_cleanup(
1387
+ task_id: str | None, adhoc: bool, repo_opt: str | None, force: bool, as_json: bool
1388
+ ) -> None:
1389
+ repo = _resolve_repo(repo_opt)
1390
+ session = _agent_session()
1391
+ task_slug = "adhoc" if adhoc else _sanitize(task_id) if task_id else None
1392
+ if task_slug is None:
1393
+ raise click.ClickException("cos pr cleanup needs --task <id> or --adhoc.")
1394
+ wt, branch = _resolve_worktree(repo, task_slug, session)
1395
+ _preserved_bundle: str | None = None # set when a drifted/peer dirty tree is bundled
1396
+
1397
+ # Merge-gate (TASK-530): only destroy the worktree+branch once work has landed
1398
+ # (merged/closed) or is fully on origin; --force is the human override.
1399
+ if not force:
1400
+ # Ownership gate (review finding 2): under session drift the single-candidate
1401
+ # fallback in _resolve_worktree can resolve a live PEER's worktree (same task
1402
+ # slug, different session) — destroying it would wipe active peer work. Refuse
1403
+ # only when the owner session is provably LIVE; a drifted-gone ("unknown") or
1404
+ # dead ("offline") owner still cleans up, preserving the TASK-541 drift path.
1405
+ owner_session = branch.rsplit("/", 1)[-1]
1406
+ if owner_session != session and _session_state(owner_session, repo) == "live":
1407
+ _emit(
1408
+ {
1409
+ "removed": False,
1410
+ "branch": branch,
1411
+ "owner_session": owner_session,
1412
+ "action": "worktree belongs to another live session — not removing; its owner or 'cos pr reap' will GC it, or re-run with --force",
1413
+ },
1414
+ as_json,
1415
+ )
1416
+ sys.exit(1)
1417
+ state = _pr_state(repo, branch)
1418
+ if state == "open":
1419
+ _emit(
1420
+ {
1421
+ "removed": False,
1422
+ "branch": branch,
1423
+ "pr_state": "open",
1424
+ "action": "PR still open — not removing; merge/close it, or re-run with --force",
1425
+ },
1426
+ as_json,
1427
+ )
1428
+ sys.exit(1)
1429
+ recoverable = _branch_recoverable(repo, branch, _integration_branch(repo))
1430
+ # Unpushed work with no landing PR: refuse and tell the user to submit, keeping
1431
+ # the branch intact — friendlier than bundle+delete for an interactive cleanup,
1432
+ # and the reaper is the GC path for a genuinely dead owner.
1433
+ if state in {"none", "unknown"} and not recoverable:
1434
+ _emit(
1435
+ {
1436
+ "removed": False,
1437
+ "branch": branch,
1438
+ "pr_state": state,
1439
+ "action": "branch has local commits not on origin — 'cos pr submit' first, or --force to discard",
1440
+ },
1441
+ as_json,
1442
+ )
1443
+ sys.exit(1)
1444
+ # Preserve-before-destroy net (TASK-566 H): for any OTHER state (merged/closed)
1445
+ # a branch that is unrecoverable (squash-merge, or extra local commits not on
1446
+ # origin) or has a dirty tree must be bundled before `branch -D`. The old code
1447
+ # bundled only a DIRTY drifted tree, so a CLEAN-tree merged branch with unpushed
1448
+ # commits was discarded with NO bundle. A FAILED status reads as "maybe dirty"
1449
+ # so a transient git error can't pass as clean and wipe work (review finding F).
1450
+ # Mirrors _reap_one's safety arm — cleanup and reap no longer diverge.
1451
+ _status = _git(["status", "--porcelain"], cwd=wt)
1452
+ dirty = _status.returncode != 0 or bool(_status.stdout.strip())
1453
+ if not recoverable or dirty:
1454
+ _preserved_bundle = _preserve_reaped(repo, wt, branch)
1455
+ if _preserved_bundle is None:
1456
+ _emit(
1457
+ {
1458
+ "removed": False,
1459
+ "branch": branch,
1460
+ "pr_state": state,
1461
+ "action": "branch has unpushed commits or an uncommitted tree and preservation failed — recover it manually, or --force to discard.",
1462
+ },
1463
+ as_json,
1464
+ )
1465
+ sys.exit(1)
1466
+
1467
+ _git(["worktree", "unlock", str(wt)], cwd=repo) # release the pr-mode live-lock
1468
+ removed_wt = _git(["worktree", "remove", "--force", str(wt)], cwd=repo).returncode == 0
1469
+ deleted_branch = _git(["branch", "-D", branch], cwd=repo).returncode == 0
1470
+ _git(["worktree", "prune"], cwd=repo)
1471
+ _heal_budget_clear(repo, branch) # branch is done — drop its heal budget (finding 8)
1472
+ _emit(
1473
+ {
1474
+ "worktree_removed": removed_wt,
1475
+ "branch_deleted": deleted_branch,
1476
+ "worktree": str(wt),
1477
+ "forced": force,
1478
+ "preserved_bundle": _preserved_bundle,
1479
+ },
1480
+ as_json,
1481
+ )
1482
+
1483
+
1484
+ # --------------------------------------------------------------------------- #
1485
+ # orphan reaper (TASK-519) — owner-independent GC keyed on presence-offline.
1486
+ # A crashed agent never cleans up after itself (the exact Rule-21 failure mode),
1487
+ # so an out-of-band sweep does it. SPEC: docs/playbooks/pr-workflow.md § 7.
1488
+ # --------------------------------------------------------------------------- #
1489
+ def _session_state(session: str, repo: str) -> str:
1490
+ # Three-state liveness, reaped only on POSITIVE death evidence: "offline"
1491
+ # (>=1 record, ALL proving death — ended_at set, or a SAME-HOST recorded pid no
1492
+ # longer alive), "live" (a record whose owner could still be working),
1493
+ # "unknown" (no matching record). session_presence()=="offline" is NOT the
1494
+ # death oracle: it also fires for a PID-alive agent merely idle >30min (a long
1495
+ # build or model turn), and reaping that destroys live uncommitted work
1496
+ # (finding 1). The reaper reaps "offline" outright and "unknown" only when the
1497
+ # worktree is also stale-by-age (finding 2).
1498
+ try:
1499
+ from core.board_os.presence import pid_alive
1500
+ except Exception:
1501
+ return "unknown" # presence module absent → never positively offline
1502
+ this_host = socket.gethostname()
1503
+ state_dir = Path(repo) / ".coding-os"
1504
+ saw_dead = False
1505
+ for sess_dir in state_dir.glob("*/sessions"):
1506
+ jf = sess_dir / f"{session}.json"
1507
+ if not jf.is_file():
1508
+ continue
1509
+ try:
1510
+ data = json.loads(jf.read_text(encoding="utf-8"))
1511
+ except (OSError, json.JSONDecodeError):
1512
+ continue # unreadable (e.g. mid-write) → not proof of death; keep checking
1513
+ pid = int(data.get("pid") or 0)
1514
+ # pid_alive is host-local: a foreign-host pid happening to be free here is
1515
+ # NOT death (L5). Trust it only same-host; legacy records (no host) default
1516
+ # to this host so pre-upgrade orphans still reap. ended_at is host-agnostic.
1517
+ host = data.get("host") or this_host
1518
+ same_host = host == this_host
1519
+ dead = data.get("ended_at") is not None or (same_host and pid > 0 and not pid_alive(pid))
1520
+ if not dead:
1521
+ return "live" # alive owner (or no same-host death proof) → keep, fail-safe
1522
+ saw_dead = True
1523
+ return "offline" if saw_dead else "unknown"
1524
+
1525
+
1526
+ def _worktree_stale(wt: Path) -> bool:
1527
+ # A no-presence-record orphan is reapable only once its worktree has been idle
1528
+ # past COS_PR_ORPHAN_MAX_AGE (default 24h), measured by the NEWEST file mtime
1529
+ # anywhere in the tree (excluding .git) — NOT the top-level dir mtime, which
1530
+ # never moves when a live agent edits nested files like src/** (finding 2), so
1531
+ # using it would reap a long-running agent's worktree mid-edit. Stops early on
1532
+ # the first fresh file, so a live worktree costs only a shallow walk.
1533
+ max_age = _env_int("COS_PR_ORPHAN_MAX_AGE", 86400)
1534
+ cutoff = time.time() - max_age
1535
+ try:
1536
+ newest = wt.stat().st_mtime
1537
+ except OSError:
1538
+ return False # can't determine age → keep (fail safe)
1539
+ if newest > cutoff:
1540
+ return False
1541
+ for root, dirs, files in os.walk(wt):
1542
+ if ".git" in dirs:
1543
+ dirs.remove(".git")
1544
+ for name in files:
1545
+ if name == ".git":
1546
+ continue # linked-worktree .git pointer — creation metadata, not activity
1547
+ try:
1548
+ mtime = (Path(root) / name).stat().st_mtime
1549
+ except OSError:
1550
+ continue
1551
+ if mtime > cutoff:
1552
+ return False # fresh activity anywhere → not stale
1553
+ if mtime > newest:
1554
+ newest = mtime
1555
+ return (time.time() - newest) > max_age
1556
+
1557
+
1558
+ def _owner_pid_host(repo: str, session: str) -> tuple[int, str]:
1559
+ # The agent runtime pid (the $PPID the presence hook records) + its host, read
1560
+ # from THIS session's presence record while it still exists at `cos pr open`.
1561
+ # Snapshotting it into the worktree lock reason lets the reaper recognise a
1562
+ # live owner even after the presence record is later rotated or deleted.
1563
+ state_dir = Path(repo) / ".coding-os"
1564
+ for sess_dir in sorted(state_dir.glob("*/sessions")):
1565
+ jf = sess_dir / f"{session}.json"
1566
+ if not jf.is_file():
1567
+ continue
1568
+ try:
1569
+ data = json.loads(jf.read_text(encoding="utf-8"))
1570
+ except (OSError, json.JSONDecodeError):
1571
+ continue
1572
+ pid = int(data.get("pid") or 0)
1573
+ if pid > 0:
1574
+ return pid, data.get("host") or socket.gethostname()
1575
+ return 0, ""
1576
+
1577
+
1578
+ def _live_lock_reason(repo: str, session: str) -> str:
1579
+ # Worktree lock reason carrying owner=<pid>@<host> when derivable, so the
1580
+ # reaper can skip a live owner whose presence record vanished. Falls back to
1581
+ # the bare reason (back-compat) when no presence pid is available.
1582
+ pid, host = _owner_pid_host(repo, session)
1583
+ base = f"pr-mode session {session}"
1584
+ return f"{base} owner={pid}@{host}" if pid > 0 else base
1585
+
1586
+
1587
+ def _worktree_lock_reason(repo: str, wt: Path) -> str:
1588
+ # `git worktree list --porcelain` emits `locked <reason>` verbatim for a locked
1589
+ # worktree; return the reason of the block whose path resolves to wt.
1590
+ out = _git_out(["worktree", "list", "--porcelain"], cwd=repo)
1591
+ target = wt.resolve()
1592
+ current: Path | None = None
1593
+ for line in out.splitlines():
1594
+ if line.startswith("worktree "):
1595
+ try:
1596
+ current = Path(line[len("worktree ") :]).resolve()
1597
+ except OSError:
1598
+ current = None
1599
+ elif line.startswith("locked") and current == target:
1600
+ return line[len("locked") :].strip()
1601
+ return ""
1602
+
1603
+
1604
+ def _worktree_index(repo: str) -> dict[Path, dict]:
1605
+ # One `git worktree list --porcelain` dump → {resolved path: {"branch","locked"}}.
1606
+ # The reaper sweep reads branch + lock reason from this instead of re-forking the
1607
+ # full list (and a rev-parse) per candidate — O(N) per sweep, not O(K·N), on a
1608
+ # path pr-reap.sh backgrounds at every SessionStart.
1609
+ index: dict[Path, dict] = {}
1610
+ cur: Path | None = None
1611
+ for line in _git_out(["worktree", "list", "--porcelain"], cwd=repo).splitlines():
1612
+ if line.startswith("worktree "):
1613
+ try:
1614
+ cur = Path(line[len("worktree ") :].strip()).resolve()
1615
+ except OSError:
1616
+ cur = None
1617
+ if cur is not None:
1618
+ index[cur] = {"branch": "", "locked": ""}
1619
+ elif cur is not None and cur in index:
1620
+ if line.startswith("branch "):
1621
+ index[cur]["branch"] = _unqualify_head(line[len("branch ") :].strip())
1622
+ elif line.startswith("locked"):
1623
+ index[cur]["locked"] = line[len("locked") :].strip()
1624
+ return index
1625
+
1626
+
1627
+ def _lock_owner_alive(repo: str, wt: Path, reason: str | None = None) -> bool:
1628
+ # True when the lock reason names an owner=<pid> alive on THIS host — the owner
1629
+ # agent is still up despite a missing presence record, so an unknown + age-stale
1630
+ # worktree must NOT be reaped. Pass `reason` from a hoisted _worktree_index to skip
1631
+ # the per-candidate porcelain fork. Owner liveness is host-local: a pid only proves
1632
+ # life on the host that stamped it, so a CLEAN host token (ASCII, no C-quote
1633
+ # artifacts) that differs from ours is a FOREIGN owner → never a keep (a foreign pid
1634
+ # could collide with a live local pid). A non-ASCII host gets the whole reason
1635
+ # C-quoted by git, so an unclean/empty host falls back to the pid-only check (a live
1636
+ # local owner with a mangled host is not wrongly reaped); the reap preserves work first.
1637
+ text = reason if reason is not None else _worktree_lock_reason(repo, wt)
1638
+ match = re.search(r"owner=(\d+)(?:@(\S+))?", text)
1639
+ if not match:
1640
+ return False
1641
+ pid, host = int(match.group(1)), (match.group(2) or "")
1642
+ host_is_clean = bool(host) and '"' not in host and "\\" not in host
1643
+ if host_is_clean and host != socket.gethostname():
1644
+ return False
1645
+ try:
1646
+ from core.board_os.presence import pid_alive
1647
+ except Exception:
1648
+ return False
1649
+ return pid_alive(pid)
1650
+
1651
+
1652
+ def _ledger_path(repo: str) -> Path:
1653
+ return Path(repo) / ".coding-os" / ".pr-cleanup-ledger.json"
1654
+
1655
+
1656
+ def _ledger_load(repo: str) -> list[dict]:
1657
+ path = _ledger_path(repo)
1658
+ if not path.is_file():
1659
+ return []
1660
+ try:
1661
+ return json.loads(path.read_text(encoding="utf-8")) or []
1662
+ except (OSError, json.JSONDecodeError):
1663
+ return []
1664
+
1665
+
1666
+ def _ledger_save(repo: str, entries: list[dict]) -> None:
1667
+ path = _ledger_path(repo)
1668
+ path.parent.mkdir(parents=True, exist_ok=True)
1669
+ # pid-unique tmp so two concurrent writers can't replace() a name the other
1670
+ # already renamed away (mirrors presence_write.py).
1671
+ tmp = path.with_name(path.name + f".tmp.{os.getpid()}")
1672
+ tmp.write_text(json.dumps(entries, indent=2), encoding="utf-8")
1673
+ tmp.replace(path) # atomic record-verify: the rename is the commit point
1674
+
1675
+
1676
+ def _ledger_record(repo: str, branch: str, remote_pending: bool, pr_pending: bool) -> None:
1677
+ entries = [e for e in _ledger_load(repo) if e.get("branch") != branch]
1678
+ entries.append({"branch": branch, "remote_pending": remote_pending, "pr_pending": pr_pending})
1679
+ _ledger_save(repo, entries)
1680
+
1681
+
1682
+ def _drain_ledger(repo: str) -> list[str]:
1683
+ # Retry the network-bound steps (remote delete + PR close) for entries an
1684
+ # offline/partial reap could not finish; drop the ones that now complete.
1685
+ entries = _ledger_load(repo)
1686
+ if not entries:
1687
+ return []
1688
+ drained: list[str] = []
1689
+ kept: list[dict] = []
1690
+ for entry in entries:
1691
+ branch = entry.get("branch")
1692
+ if not branch:
1693
+ continue # malformed/legacy entry — skip rather than abort the drain
1694
+ remote_pending = entry.get("remote_pending", False)
1695
+ pr_pending = entry.get("pr_pending", False)
1696
+ if remote_pending and _has_remote(repo):
1697
+ remote_pending = _git(["push", "origin", "--delete", branch], cwd=repo).returncode != 0
1698
+ if pr_pending and _gh_ready():
1699
+ pr_pending = not _pr_close(repo, branch)
1700
+ if not remote_pending and not pr_pending:
1701
+ drained.append(branch)
1702
+ else:
1703
+ kept.append(
1704
+ {"branch": branch, "remote_pending": remote_pending, "pr_pending": pr_pending}
1705
+ )
1706
+ _ledger_save(repo, kept)
1707
+ return drained
1708
+
1709
+
1710
+ def _pr_close(repo: str, branch: str) -> bool:
1711
+ # True when the branch has no open PR (already drained) or the close succeeds
1712
+ # — so a branch that never had a PR can't churn the ledger forever (finding 11).
1713
+ listing = _run(
1714
+ ["gh", "pr", "list", "--head", branch, "--state", "open", "--json", "number"], cwd=repo
1715
+ )
1716
+ if listing.returncode != 0:
1717
+ return False # couldn't list (timeout/error) → keep the ledger entry, retry later
1718
+ try:
1719
+ has_open = bool(json.loads(listing.stdout or "[]"))
1720
+ except json.JSONDecodeError:
1721
+ has_open = True # unparseable listing → assume a PR may exist and try to close
1722
+ if not has_open:
1723
+ return True
1724
+ return _run(["gh", "pr", "close", branch], cwd=repo).returncode == 0
1725
+
1726
+
1727
+ def _reap_one(repo: str, wt: Path, branch: str) -> dict:
1728
+ # The worktree is a re-creatable checkout; the branch commits + uncommitted changes
1729
+ # are the WORK and must survive (TASK-535). So: preserve whenever the branch is not
1730
+ # already on origin/integration OR the tree is dirty, and GC the worktree + delete
1731
+ # the branch ONLY once the work is safe — on a remote ref, or a confirmed bundle.
1732
+ # If preservation fails, keep BOTH the worktree and the branch (D2).
1733
+ integration = _integration_branch(repo)
1734
+ recoverable = _branch_recoverable(repo, branch, integration)
1735
+ dirty = bool(_git_out(["status", "--porcelain"], cwd=wt))
1736
+ preserved = _preserve_reaped(repo, wt, branch) if (not recoverable or dirty) else None
1737
+ # Dirty uncommitted work is safe only if preservation captured it (it commits the
1738
+ # dirty tree onto the branch, then bundles) — `recoverable` alone covers only the
1739
+ # COMMITTED branch, so recoverable+dirty+preserve-failed must NOT count as safe (D2).
1740
+ work_safe = (recoverable and not dirty) or preserved is not None
1741
+
1742
+ _git(["worktree", "unlock", str(wt)], cwd=repo) # offline worktrees may be locked
1743
+ # Destroy the worktree ONLY once the work is safe (on a remote/integration ref or
1744
+ # bundled). When preservation failed, the worktree may hold the only copy of the
1745
+ # reaped work — keep it AND the branch for manual recovery, flagged needs_attention
1746
+ # (D2). A later sweep retries preservation and removes it once it succeeds.
1747
+ local = remote_pending = pr_pending = removed = False
1748
+ if work_safe:
1749
+ removed = _git(["worktree", "remove", "--force", str(wt)], cwd=repo).returncode == 0
1750
+ local = _git(["branch", "-D", branch], cwd=repo).returncode == 0
1751
+ if _has_remote(repo):
1752
+ remote_pending = _git(["push", "origin", "--delete", branch], cwd=repo).returncode != 0
1753
+ pr_pending = _gh_ready() and not _pr_close(repo, branch)
1754
+ _git(["worktree", "prune"], cwd=repo)
1755
+ _heal_budget_clear(repo, branch) # owner is gone — drop its heal budget (finding 8)
1756
+ if remote_pending or pr_pending:
1757
+ _ledger_record(repo, branch, remote_pending, pr_pending) # drains on next online sweep
1758
+ return {
1759
+ "worktree": str(wt),
1760
+ "branch": branch,
1761
+ "worktree_removed": removed,
1762
+ "local_deleted": local,
1763
+ "remote_pending": remote_pending,
1764
+ "pr_pending": pr_pending,
1765
+ "recoverable": recoverable,
1766
+ "preserved": preserved,
1767
+ "needs_attention": not work_safe, # branch kept: not on origin AND bundle failed
1768
+ }
1769
+
1770
+
1771
+ @pr_group.command(
1772
+ "reap", help="GC worktrees/branches/PRs of presence-offline sessions; drain the cleanup ledger."
1773
+ )
1774
+ @click.option("--repo", "repo_opt", default=None)
1775
+ @click.option("--dry-run", is_flag=True, help="Report what would be reaped; change nothing.")
1776
+ @click.option("--json", "as_json", is_flag=True)
1777
+ def pr_reap(repo_opt: str | None, dry_run: bool, as_json: bool) -> None:
1778
+ repo = _resolve_repo(repo_opt)
1779
+ # One reaper per repo at a time — pr-reap.sh backgrounds this on EVERY
1780
+ # SessionStart, so N concurrent sessions would otherwise double-GC the same
1781
+ # orphan and clobber each other's ledger writes (finding 2). A peer holding
1782
+ # the lock already covers this repo, so we bow out cleanly.
1783
+ # Closing the fd on context exit releases the flock.
1784
+ lock_path = Path(repo) / ".coding-os" / ".pr-reap.lock"
1785
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
1786
+ with open(lock_path, "w", encoding="utf-8") as lock_fd:
1787
+ if fcntl is not None:
1788
+ try:
1789
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
1790
+ except OSError:
1791
+ _emit(
1792
+ {
1793
+ "reaped": 0,
1794
+ "kept_live": 0,
1795
+ "ledger_drained": "(skipped: reaper already running)",
1796
+ },
1797
+ as_json,
1798
+ )
1799
+ return
1800
+ wt_root = _worktree_root(repo)
1801
+ drained = [] if dry_run else _drain_ledger(repo)
1802
+ reaped: list[dict] = []
1803
+ kept: list[dict] = []
1804
+ wt_index = _worktree_index(repo) # one porcelain dump for the whole sweep
1805
+ if wt_root.is_dir():
1806
+ for wt in sorted(p for p in wt_root.iterdir() if p.is_dir()):
1807
+ entry = wt_index.get(wt.resolve(), {})
1808
+ branch = entry.get("branch") or _git_out(
1809
+ ["rev-parse", "--abbrev-ref", "HEAD"], cwd=wt
1810
+ )
1811
+ if not branch.startswith("agents/"):
1812
+ continue
1813
+ session = branch.rsplit("/", 1)[-1]
1814
+ state = _session_state(session, repo)
1815
+ reapable = state == "offline" or (
1816
+ state == "unknown"
1817
+ and _worktree_stale(wt)
1818
+ and not _lock_owner_alive(repo, wt, reason=entry.get("locked", ""))
1819
+ )
1820
+ if reapable:
1821
+ reaped.append(
1822
+ {"worktree": str(wt), "branch": branch, "would_reap": True}
1823
+ if dry_run
1824
+ else _reap_one(repo, wt, branch)
1825
+ )
1826
+ else:
1827
+ if not dry_run:
1828
+ # Re-assert the lock so a peer's prune can't drop a live checkout (§2).
1829
+ # A no-op when already locked (the common case), so it preserves the
1830
+ # owner=pid@host stamp the open wrote — that is what a later sweep reads.
1831
+ _git(
1832
+ ["worktree", "lock", str(wt), "--reason", "pr-mode live session"],
1833
+ cwd=repo,
1834
+ )
1835
+ kept.append({"worktree": str(wt), "branch": branch, "live": True})
1836
+ _emit(
1837
+ {
1838
+ "reaped": len(reaped),
1839
+ "kept_live": len(kept),
1840
+ "ledger_drained": ",".join(drained) or "(none)",
1841
+ "detail": reaped if as_json else f"{len(reaped)} reaped",
1842
+ },
1843
+ as_json,
1844
+ )
1845
+
1846
+
1847
+ # --------------------------------------------------------------------------- #
1848
+ # bounded self-heal + autonomy circuit-breaker (TASK-520) — the autonomous loop
1849
+ # can never burn unbounded tokens / CI-quota. SPEC: docs/playbooks/pr-workflow.md § 8.
1850
+ # --------------------------------------------------------------------------- #
1851
+ def _heal_budget_path(repo: str) -> Path:
1852
+ return Path(repo) / ".coding-os" / ".pr-heal-budget.json"
1853
+
1854
+
1855
+ @contextlib.contextmanager
1856
+ def _heal_lock(repo: str):
1857
+ # Serialize the heal-budget read-modify-write so concurrent agents can't clobber
1858
+ # each other's counts (L4). DEDICATED lock file — never .pr-reap.lock — because
1859
+ # _reap_one runs under the reap flock and calls _heal_budget_clear; reusing the
1860
+ # reap lock would re-enter and deadlock. Degrades to a no-op on Windows (fcntl
1861
+ # None); the lost-update there is acceptable (heal counts are advisory).
1862
+ if fcntl is None:
1863
+ yield
1864
+ return
1865
+ lock_path = Path(repo) / ".coding-os" / ".pr-heal.lock"
1866
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
1867
+ with open(lock_path, "w", encoding="utf-8") as lock_fd:
1868
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
1869
+ yield
1870
+
1871
+
1872
+ def _heal_budget(repo: str) -> dict:
1873
+ path = _heal_budget_path(repo)
1874
+ if not path.is_file():
1875
+ return {}
1876
+ try:
1877
+ return json.loads(path.read_text(encoding="utf-8")) or {}
1878
+ except (OSError, json.JSONDecodeError):
1879
+ return {}
1880
+
1881
+
1882
+ def _heal_budget_save(repo: str, data: dict) -> None:
1883
+ path = _heal_budget_path(repo)
1884
+ path.parent.mkdir(parents=True, exist_ok=True)
1885
+ # pid-unique tmp — a process-shared name races on replace() (mirrors
1886
+ # presence_write.py).
1887
+ tmp = path.with_name(path.name + f".tmp.{os.getpid()}")
1888
+ tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
1889
+ tmp.replace(path)
1890
+
1891
+
1892
+ def _heal_budget_clear(repo: str, branch: str) -> None:
1893
+ # Drop a branch's heal count on success/cleanup so a later re-open is never
1894
+ # pre-escalated by a stale count and the file can't grow unbounded (finding 8).
1895
+ with _heal_lock(repo):
1896
+ budget = _heal_budget(repo)
1897
+ if branch in budget:
1898
+ del budget[branch]
1899
+ _heal_budget_save(repo, budget)
1900
+
1901
+
1902
+ def _env_int(name: str, default: int) -> int:
1903
+ try:
1904
+ return max(1, int(os.environ.get(name, str(default))))
1905
+ except ValueError:
1906
+ return default
1907
+
1908
+
1909
+ def _open_pr_count(repo: str, session: str) -> int:
1910
+ # Open PRs for THIS session only (branch agents/<task>/<session>) — the cap is
1911
+ # per-session (playbook §8), so a peer's PRs never starve this agent and a
1912
+ # stray human agents/* branch never inflates it (finding 7). Returns -1 for
1913
+ # "could not determine" (no gh, or `gh pr list` errored/timed out): the count
1914
+ # is unknown in exactly the gh-down/quota-dead scenario the breaker exists for,
1915
+ # so the submit caller must treat -1 as cap-reached and fail SAFE — counting it
1916
+ # as 0 would let the unbounded push through (M1). A genuinely remote-less repo
1917
+ # uses the `local` rung and never reaches this.
1918
+ if not _gh_ready():
1919
+ return -1
1920
+ proc = _run(
1921
+ [
1922
+ "gh",
1923
+ "pr",
1924
+ "list",
1925
+ "--search",
1926
+ "head:agents/",
1927
+ "--state",
1928
+ "open",
1929
+ "--json",
1930
+ "headRefName",
1931
+ ],
1932
+ cwd=repo,
1933
+ )
1934
+ if proc.returncode != 0:
1935
+ return -1
1936
+ try:
1937
+ prs = json.loads(proc.stdout or "[]")
1938
+ except json.JSONDecodeError:
1939
+ return -1
1940
+ return sum(1 for p in prs if str(p.get("headRefName", "")).rsplit("/", 1)[-1] == session)
1941
+
1942
+
1943
+ def _escalate_blocked(repo: str, task_id: str | None, summary: str, move_reason: str) -> bool:
1944
+ # Generic "move the board task to blocked + log why" — callers own the wording
1945
+ # (heal: budget exhausted; submit: auto-merge deadlock) so the work-log line is
1946
+ # accurate per cause rather than always reading "self-heal".
1947
+ if not task_id:
1948
+ return False
1949
+ try:
1950
+ from cli.board_commands import _agent_session_id, _db_conn
1951
+ from core.board_os.mcp_tools import cos_task_move, cos_work_log_append
1952
+
1953
+ conn = _db_conn()
1954
+ cos_work_log_append(conn, task_id=task_id, summary=summary)
1955
+ env = json.loads(
1956
+ cos_task_move(
1957
+ conn,
1958
+ task_id=task_id,
1959
+ to="blocked",
1960
+ reason=move_reason,
1961
+ agent_session=_agent_session_id(),
1962
+ )
1963
+ )
1964
+ return bool(env.get("ok"))
1965
+ except Exception:
1966
+ return False # no board / unavailable → escalation signal still returned to caller
1967
+
1968
+
1969
+ @pr_group.command(
1970
+ "heal",
1971
+ help="Record a self-heal attempt on a red PR; escalate to blocked when the budget is spent.",
1972
+ )
1973
+ @click.option("--task", "task_id", default=None)
1974
+ @click.option("--adhoc", is_flag=True)
1975
+ @click.option("--repo", "repo_opt", default=None)
1976
+ @click.option("--reason", default="CI red", help="Failure summary recorded on escalation.")
1977
+ @click.option("--json", "as_json", is_flag=True)
1978
+ def pr_heal(
1979
+ task_id: str | None, adhoc: bool, repo_opt: str | None, reason: str, as_json: bool
1980
+ ) -> None:
1981
+ repo = _resolve_repo(repo_opt)
1982
+ session = _agent_session()
1983
+ task_slug = "adhoc" if adhoc else _sanitize(task_id) if task_id else None
1984
+ if task_slug is None:
1985
+ raise click.ClickException("cos pr heal needs --task <id> or --adhoc.")
1986
+ branch = _branch_for(task_slug, session)
1987
+ # Read-modify-write under the dedicated heal flock so concurrent agents can't
1988
+ # clobber the count (L4).
1989
+ with _heal_lock(repo):
1990
+ budget = _heal_budget(repo)
1991
+ count = int(budget.get(branch, 0)) + 1
1992
+ budget[branch] = count
1993
+ _heal_budget_save(repo, budget)
1994
+ max_n = _env_int("COS_PR_HEAL_MAX", 3)
1995
+
1996
+ if count > max_n:
1997
+ blocked = _escalate_blocked(
1998
+ repo,
1999
+ task_id,
2000
+ f"pr-mode self-heal budget exhausted after {count} attempts: {reason}",
2001
+ f"pr-mode heal budget exhausted ({reason})",
2002
+ )
2003
+ _emit(
2004
+ {
2005
+ "branch": branch,
2006
+ "attempt": count,
2007
+ "max": max_n,
2008
+ "escalated": True,
2009
+ "board_blocked": blocked,
2010
+ "action": "STOP re-pushing — task escalated to blocked",
2011
+ },
2012
+ as_json,
2013
+ )
2014
+ sys.exit(2)
2015
+ _emit(
2016
+ {
2017
+ "branch": branch,
2018
+ "attempt": count,
2019
+ "max": max_n,
2020
+ "escalated": False,
2021
+ "action": f"heal attempt {count}/{max_n} — fix and re-push",
2022
+ },
2023
+ as_json,
2024
+ )