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
@@ -0,0 +1,3228 @@
1
+ """board_os MCP tools — `cos_task_*` surface.
2
+
3
+ Implements board MCP tools, including:
4
+ cos_task_create, cos_task_board, cos_task_move, cos_task_reposition,
5
+ cos_task_pick, cos_task_daily, cos_task_retro, cos_task_wip_check,
6
+ cos_work_log_append
7
+
8
+ All tools use the shared ok()/fail()/@safe_tool envelope (Rule 14).
9
+ They are registered into the MCP server in
10
+ `core/thinking_os/server.py` via the `register_board_tools(mcp, conn)`
11
+ helper at the bottom of this module.
12
+
13
+ Stateless from the caller's perspective:
14
+ - Open one connection per call (via the server's connection factory),
15
+ - call the underlying board_os primitives (config.load_config,
16
+ parser.parse_task, sync.sync_one, workflow.transition),
17
+ - shape the response into ok()/fail() with token-budgeted meta.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import json
24
+ import logging
25
+ import os
26
+ import re
27
+ import sqlite3
28
+ import time
29
+ from datetime import datetime
30
+ from pathlib import Path
31
+
32
+ from board_os.config import (
33
+ APPETITE_RE,
34
+ KIND_ENUM,
35
+ PRIORITY_ENUM,
36
+ READY_LABEL,
37
+ STATUS_ENUM,
38
+ TASK_ID_FORMAT_RE,
39
+ load_config,
40
+ )
41
+ from board_os._agent_runtime import SYSTEM_SESSION_PREFIX
42
+ from board_os.parser import parse_task
43
+ from board_os.sync import sync_one
44
+ from board_os.workflow import (
45
+ _format_yaml_scalar_token,
46
+ _has_task_dependencies_table,
47
+ check_wip,
48
+ dependents_of,
49
+ incomplete_dependencies,
50
+ patch_task_frontmatter_scalars,
51
+ transition,
52
+ validate_dependencies_no_cycle,
53
+ )
54
+ from thinking_os.tools._shared import TOKEN_BUDGET_CHARS, _budget_size, fail, ok, safe_tool
55
+
56
+ logger = logging.getLogger("coding_os.board_os.mcp_tools")
57
+
58
+ _SLUG_RE = re.compile(r"[^a-z0-9]+")
59
+
60
+
61
+ # ---------- Internal helpers ----------
62
+
63
+
64
+ def _project_root() -> Path:
65
+ from thinking_os.database import project_root
66
+
67
+ return project_root()
68
+
69
+
70
+ def _current_config():
71
+ try:
72
+ return load_config(_project_root())
73
+ except FileNotFoundError:
74
+ return None
75
+
76
+
77
+ def _slugify(title: str, *, max_len: int = 60) -> str:
78
+ slug = _SLUG_RE.sub("-", title.lower()).strip("-")
79
+ return slug[:max_len] or "untitled"
80
+
81
+
82
+ def _derive_ns_from_git(project_root: Path) -> str:
83
+ # Stable, low-collision uppercase NS from git user.email — the zero-config
84
+ # fallback for the namespaced scheme. 4 base36 chars of a sha1: readable
85
+ # enough as a namespace, collision-rare; docs recommend an explicit prefix.
86
+ import hashlib
87
+ import string
88
+ import subprocess
89
+
90
+ try:
91
+ email = subprocess.run(
92
+ ["git", "-C", str(project_root), "config", "user.email"],
93
+ capture_output=True,
94
+ text=True,
95
+ timeout=3,
96
+ ).stdout.strip()
97
+ except (OSError, subprocess.SubprocessError):
98
+ email = ""
99
+ if not email:
100
+ return ""
101
+ alphabet = string.ascii_uppercase + string.digits
102
+ n = int(hashlib.sha1(email.encode()).hexdigest()[:12], 16)
103
+ out = ""
104
+ for _ in range(4):
105
+ out += alphabet[n % len(alphabet)]
106
+ n //= len(alphabet)
107
+ return ("T" + out[1:]) if not out[0].isalpha() else out
108
+
109
+
110
+ def _namespace_segment(project_root: Path) -> str:
111
+ # '' when no valid namespace → caller degrades to plain TASK-NNN. The scheme
112
+ # gate lives in the dispatcher, not here.
113
+ try:
114
+ from board_os.config import load_config
115
+
116
+ cfg = load_config(project_root)
117
+ except Exception as exc:
118
+ logger.debug("namespace segment resolve failed: %s", exc)
119
+ return ""
120
+ ns = (getattr(cfg, "task_id_prefix", "") or "").strip().upper()
121
+ if not ns:
122
+ ns = _derive_ns_from_git(project_root)
123
+ if not re.match(r"^[A-Z][A-Z0-9]{1,7}$", ns):
124
+ return ""
125
+ return f"{ns}-"
126
+
127
+
128
+ def _allocate_with_prefix(conn: sqlite3.Connection, project_root: Path, id_prefix: str) -> str:
129
+ # Atomic per-prefix counter: one INSERT…SELECT computes max(db, fs)+1 for
130
+ # THIS id_prefix AND reserves the row, so SQLite's write lock serializes
131
+ # concurrent local creators. The per-prefix max keeps each namespace an
132
+ # independent sequence (un-synced contributors never collide). id_prefix is
133
+ # validated safe chars (TASK- + uppercase NS + dash) → safe to interpolate.
134
+ substr_start = len(id_prefix) + 1 # 1-indexed SQL SUBSTR past the prefix
135
+ like_pat = id_prefix + "%"
136
+
137
+ tasks_dir = project_root / "docs" / "tasks"
138
+ num_re = re.compile(re.escape(id_prefix) + r"(\d+)")
139
+ fs_max = 0
140
+ if tasks_dir.exists():
141
+ for p in tasks_dir.glob(f"{id_prefix}*.md"):
142
+ m = num_re.match(p.name)
143
+ if m:
144
+ fs_max = max(fs_max, int(m.group(1)))
145
+
146
+ import time as _t
147
+
148
+ sql = f"""
149
+ INSERT INTO tasks (task_id, title, status, file_path, content_hash, mtime)
150
+ SELECT printf('{id_prefix}%03d', MAX(n) + 1),
151
+ '(reserving)', 'icebox',
152
+ printf('docs/tasks/.reserve-{id_prefix}%d.tmp', MAX(n) + 1), '', 0
153
+ FROM (
154
+ SELECT COALESCE(MAX(CAST(SUBSTR(task_id, {substr_start}) AS INTEGER)), 0) AS n
155
+ FROM tasks
156
+ WHERE task_id LIKE ? AND SUBSTR(task_id, {substr_start}) GLOB '[0-9]*'
157
+ UNION ALL SELECT ? AS n
158
+ )
159
+ """
160
+
161
+ last_exc: Exception | None = None
162
+ for attempt in range(8):
163
+ try:
164
+ cur = conn.execute(sql, (like_pat, fs_max))
165
+ conn.commit()
166
+ row = conn.execute(
167
+ "SELECT task_id FROM tasks WHERE rowid = ?", (cur.lastrowid,)
168
+ ).fetchone()
169
+ if row and row[0]:
170
+ return str(row[0])
171
+ raise sqlite3.OperationalError("reservation row not found after insert")
172
+ except sqlite3.OperationalError as exc:
173
+ last_exc = exc
174
+ if "locked" in str(exc).lower() and attempt < 7:
175
+ _t.sleep(0.05 * (attempt + 1))
176
+ continue
177
+ raise
178
+ raise last_exc or sqlite3.OperationalError("task id allocation failed")
179
+
180
+
181
+ # Task-id allocator seam (ADR adr-task-id-allocator-seam). Each allocator mints
182
+ # the next id behind one interface; the id format stays TASK-<token>, so a future
183
+ # `forge` / `service` allocator drops in via the registry with zero migration and
184
+ # zero caller change. local + namespaced are offline; both reuse the atomic
185
+ # per-prefix counter, differing only in the prefix.
186
+ class _LocalAllocator:
187
+ def allocate(self, conn: sqlite3.Connection, project_root: Path) -> str:
188
+ return _allocate_with_prefix(conn, project_root, "TASK-")
189
+
190
+
191
+ class _NamespacedAllocator:
192
+ def allocate(self, conn: sqlite3.Connection, project_root: Path) -> str:
193
+ return _allocate_with_prefix(conn, project_root, "TASK-" + _namespace_segment(project_root))
194
+
195
+
196
+ _TASK_ID_ALLOCATORS: dict[str, object] = {
197
+ "sequential": _LocalAllocator(),
198
+ "local": _LocalAllocator(),
199
+ "namespaced": _NamespacedAllocator(),
200
+ }
201
+
202
+
203
+ def _resolve_task_id_allocator(project_root: Path):
204
+ try:
205
+ from board_os.config import load_config
206
+
207
+ scheme = getattr(load_config(project_root), "task_id_scheme", "sequential")
208
+ except Exception as exc:
209
+ logger.debug("allocator resolve fell back to local: %s", exc)
210
+ scheme = "sequential"
211
+ return _TASK_ID_ALLOCATORS.get(scheme, _TASK_ID_ALLOCATORS["sequential"])
212
+
213
+
214
+ def _next_task_id(conn: sqlite3.Connection, project_root: Path) -> str:
215
+ return _resolve_task_id_allocator(project_root).allocate(conn, project_root)
216
+
217
+
218
+ # external_ref — optional bidirectional link to a forge issue/PR. Metadata only;
219
+ # never the task's canonical id (ADR adr-task-id-allocator-seam). Host is detected
220
+ # from the origin remote, so the kernel hardcodes no forge (P2).
221
+ def _detect_forge(project_root: Path) -> str:
222
+ import subprocess
223
+
224
+ try:
225
+ url = (
226
+ subprocess.run(
227
+ ["git", "-C", str(project_root), "remote", "get-url", "origin"],
228
+ capture_output=True,
229
+ text=True,
230
+ timeout=3,
231
+ )
232
+ .stdout.strip()
233
+ .lower()
234
+ )
235
+ except (OSError, subprocess.SubprocessError):
236
+ return ""
237
+ if "github.com" in url:
238
+ return "github"
239
+ if "gitlab" in url:
240
+ return "gitlab"
241
+ if "bitbucket" in url:
242
+ return "bitbucket"
243
+ return ""
244
+
245
+
246
+ def _normalize_external_ref(raw: str, project_root: Path) -> str | None:
247
+ # Accepts a bare number, '#42', 'github#42', or a full issue/PR URL → returns
248
+ # '<forge>#<n>' ('!' for a merge/pull request). Forge is taken from the ref
249
+ # when explicit, else detected from origin; None when unparseable.
250
+ import re as _re
251
+
252
+ raw = (raw or "").strip()
253
+ if not raw:
254
+ return None
255
+ m = _re.search(
256
+ r"(github|gitlab|bitbucket)\.[^/]+/.+?/(?:issues|pull|-/issues|-/merge_requests|merge_requests)/(\d+)",
257
+ raw,
258
+ )
259
+ if m:
260
+ sep = "!" if "merge_request" in raw or "/pull/" in raw else "#"
261
+ return f"{m.group(1)}{sep}{m.group(2)}"
262
+ m = _re.match(r"^(github|gitlab|bitbucket)\s*([#!])\s*(\d+)$", raw, _re.IGNORECASE)
263
+ if m:
264
+ return f"{m.group(1).lower()}{m.group(2)}{m.group(3)}"
265
+ m = _re.match(r"^([#!]?)(\d+)$", raw)
266
+ if m:
267
+ forge = _detect_forge(project_root)
268
+ if not forge:
269
+ return None
270
+ sep = "!" if m.group(1) == "!" else "#"
271
+ return f"{forge}{sep}{m.group(2)}"
272
+ return None
273
+
274
+
275
+ def cos_task_link(conn: sqlite3.Connection, task_id: str, ref: str) -> dict:
276
+ """Link a task to a forge issue/PR via the optional external_ref field."""
277
+ row = conn.execute("SELECT file_path FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
278
+ if not row:
279
+ return fail("not_found", f"task {task_id} not found")
280
+ project_root = _project_root()
281
+ file_path = project_root / row[0]
282
+ if not file_path.exists():
283
+ return fail("not_found", f"file missing: {file_path}")
284
+ normalized = _normalize_external_ref(ref, project_root)
285
+ if not normalized:
286
+ return fail(
287
+ "validation",
288
+ f"could not parse a forge ref from {ref!r} — use e.g. 42, github#42, or an issue URL",
289
+ )
290
+ patch_task_frontmatter_scalars(file_path, {"external_ref": normalized})
291
+ return ok({"task_id": task_id, "external_ref": normalized, "meta": {"layer": "tasks"}})
292
+
293
+
294
+ def _render_lean_frontmatter(fields: dict) -> str:
295
+ # Stable key order matches the template.
296
+ order = [
297
+ "id",
298
+ "title",
299
+ "swimlane",
300
+ "kind",
301
+ "epic",
302
+ "labels",
303
+ "status",
304
+ "priority",
305
+ "appetite",
306
+ "created",
307
+ "started",
308
+ "completed",
309
+ "agent_session",
310
+ "depends_on",
311
+ "blocked_by",
312
+ "references",
313
+ "external_ref",
314
+ ]
315
+ lines = ["---"]
316
+ for key in order:
317
+ if key not in fields:
318
+ continue
319
+ val = fields[key]
320
+ if val is None:
321
+ lines.append(f"{key}: null")
322
+ elif isinstance(val, list):
323
+ if not val:
324
+ lines.append(f"{key}: []")
325
+ else:
326
+ inner = ", ".join(
327
+ _format_yaml_scalar_token(v) if isinstance(v, str) else str(v) for v in val
328
+ )
329
+ lines.append(f"{key}: [{inner}]")
330
+ elif isinstance(val, str):
331
+ # Route every string scalar through the shared YAML-safe quoter so a
332
+ # title/value containing " or : or other specials stays valid YAML.
333
+ lines.append(f"{key}: {_format_yaml_scalar_token(val)}")
334
+ else:
335
+ lines.append(f"{key}: {val}")
336
+ lines.append("---")
337
+ return "\n".join(lines)
338
+
339
+
340
+ def _render_kind_aware_body(
341
+ *,
342
+ task_id: str,
343
+ title: str,
344
+ kind: str,
345
+ outcome: str | None,
346
+ read_first_block: str,
347
+ acceptance: str | None = None,
348
+ repro: str | None = None,
349
+ ) -> str:
350
+ # Sections a kind opts out of (e.g. chore drops Acceptance + Read First) are
351
+ # NOT emitted so the agent isn't prompted for fields it won't need. Config
352
+ # unavailable (fresh install) → fall back to the full template, never empty.
353
+ sections_to_render: dict[str, str] = {}
354
+
355
+ try:
356
+ # Lazy import — keeps this module loadable in environments where
357
+ # pydantic/yaml haven't been installed yet (fresh `cos init`).
358
+ from board_os.transition_gates import load_gates_config
359
+
360
+ config = load_gates_config()
361
+ rules = config.definition_of_ready.for_kind(kind)
362
+ active_sections = set(rules.sections.keys())
363
+ except Exception:
364
+ active_sections = {"Outcome", "Read First", "Acceptance"}
365
+
366
+ # Outcome is always rendered — it's the one universal anchor.
367
+ outcome_line = outcome or _kind_outcome_placeholder(kind)
368
+ sections_to_render["Outcome"] = f"**Outcome (one sentence):** {outcome_line}"
369
+
370
+ if "Read First" in active_sections:
371
+ sections_to_render["Read First"] = f"## Read First\n{read_first_block}"
372
+
373
+ if "Repro Steps" in active_sections:
374
+ repro_body = (
375
+ repro.strip()
376
+ if repro and repro.strip()
377
+ else ("1. (fill in: exact steps to reproduce)\n2. ...\nExpected: ...\nActual: ...")
378
+ )
379
+ sections_to_render["Repro Steps"] = "## Repro Steps\n" + repro_body
380
+
381
+ if "Threat Model" in active_sections:
382
+ sections_to_render["Threat Model"] = (
383
+ "## Threat Model\n(fill in: attacker, asset, attack vector, mitigation)"
384
+ )
385
+
386
+ if "Acceptance" in active_sections:
387
+ accept_body = (
388
+ acceptance.strip()
389
+ if acceptance and acceptance.strip()
390
+ else "- **Given** ...\n- **When** ...\n- **Then** ..."
391
+ )
392
+ sections_to_render["Acceptance"] = (
393
+ "## Acceptance (G/W/T) — *this IS the Definition of Done*\n" + accept_body
394
+ )
395
+
396
+ sections_to_render["Work Log"] = "## Work Log"
397
+
398
+ body_parts = [f"\n\n# {task_id}: {title}", ""]
399
+ # Stable ordering: Outcome → Read First → Repro Steps → Threat Model →
400
+ # Acceptance → Work Log. Mirrors the natural read order.
401
+ order = ["Outcome", "Read First", "Repro Steps", "Threat Model", "Acceptance", "Work Log"]
402
+ for name in order:
403
+ if name in sections_to_render:
404
+ body_parts.append(sections_to_render[name])
405
+ body_parts.append("")
406
+ return "\n".join(body_parts)
407
+
408
+
409
+ def _kind_outcome_placeholder(kind: str) -> str:
410
+ by_kind = {
411
+ "feature": "(fill in: one-sentence measurable outcome — e.g. 'Add OAuth login that issues 24h JWTs.')",
412
+ "bug": "(fill in: one-sentence outcome — e.g. 'Stop double-charging users on retry of failed payments.')",
413
+ "chore": "(fill in: one-sentence outcome — e.g. 'Bump dependency X to v2.3 for security patch.')",
414
+ "spike": "(fill in: one-sentence question — e.g. 'Investigate whether kuzu can replace sqlite for graph layer.')",
415
+ "docs": "(fill in: one-sentence outcome — e.g. 'Document the override-audit policy in docs/governance/.')",
416
+ "refactor": "(fill in: one-sentence outcome — e.g. 'Extract retry logic into a shared decorator with backoff.')",
417
+ "test": "(fill in: one-sentence outcome — e.g. 'Cover the OAuth refresh-token edge case at integration level.')",
418
+ "security": "(fill in: one-sentence outcome — e.g. 'Rotate all signing keys and tighten cookie SameSite.')",
419
+ }
420
+ return by_kind.get(kind, "(fill in: one-sentence measurable outcome)")
421
+
422
+
423
+ def _next_steps_for_kind(kind: str) -> dict:
424
+ try:
425
+ from board_os.transition_gates import load_gates_config
426
+
427
+ config = load_gates_config()
428
+ rules = config.definition_of_ready.for_kind(kind)
429
+ except Exception:
430
+ return {
431
+ "kind": kind,
432
+ "required_for_in_progress": [],
433
+ "command_after_fill": None,
434
+ }
435
+
436
+ required: list[dict] = []
437
+ for name, rule in rules.sections.items():
438
+ if rule is None:
439
+ continue
440
+ spec: dict = {"section": name, "required": rule.required}
441
+ if rule.min_chars:
442
+ spec["min_chars"] = rule.min_chars
443
+ if rule.min_items:
444
+ spec["min_items"] = rule.min_items
445
+ if rule.required_subitems:
446
+ spec["required_subitems"] = list(rule.required_subitems)
447
+ if rule.forbid_substrings:
448
+ spec["forbid_substrings"] = list(rule.forbid_substrings)
449
+ required.append(spec)
450
+ return {
451
+ "kind": kind,
452
+ "required_for_in_progress": required,
453
+ "command_after_fill": "cos task-start <TASK-ID>",
454
+ "preview_command": "cos task-validate <TASK-ID>",
455
+ }
456
+
457
+
458
+ def _status_dwell_seconds(now: float, started_at, last_transition_at) -> int | None:
459
+ # Reuse the reclaim derivation (max of started_at and last transition) so
460
+ # dwell, reclaim idle, and SLA staleness share one "last activity" definition.
461
+ last = max(int(started_at or 0), int(last_transition_at or 0))
462
+ if last <= 0:
463
+ return None
464
+ return max(0, int(now - last))
465
+
466
+
467
+ def _humanize_duration(seconds: int | None) -> str | None:
468
+ if seconds is None:
469
+ return None
470
+ if seconds < 3600:
471
+ return f"{seconds // 60}m"
472
+ if seconds < 86400:
473
+ return f"{seconds // 3600}h"
474
+ return f"{seconds // 86400}d"
475
+
476
+
477
+ def _task_card(row: sqlite3.Row | tuple) -> dict:
478
+ started_at = row[11] if len(row) > 11 else None
479
+ completed_at = row[12] if len(row) > 12 else None
480
+ last_transition_at = row[13] if len(row) > 13 else None
481
+ dwell = _status_dwell_seconds(time.time(), started_at, last_transition_at)
482
+ return {
483
+ "id": row[0],
484
+ "title": row[1],
485
+ "swimlane": row[2] or "",
486
+ "kind": row[3] or "",
487
+ "epic": row[4],
488
+ "labels": json.loads(row[5] or "[]"),
489
+ "status": row[6],
490
+ "priority": row[7] or "P2",
491
+ "appetite": row[8] or "1d",
492
+ "agent_session": row[9],
493
+ "last_log_line": _last_log_line(row[10]),
494
+ "completion_evidence": _completion_evidence(row[10]),
495
+ "started_at": started_at,
496
+ "completed_at": completed_at,
497
+ "last_transition_at": last_transition_at,
498
+ "status_dwell_seconds": dwell,
499
+ "status_dwell_human": _humanize_duration(dwell),
500
+ }
501
+
502
+
503
+ def _sla_threshold_seconds(status: str, config) -> int | None:
504
+ if config is None:
505
+ return None
506
+ policy = config.workflow_policy
507
+ hours = {
508
+ "in_progress": policy.in_progress_sla_hours,
509
+ "testing": policy.testing_sla_hours,
510
+ "blocked": policy.blocked_sla_hours,
511
+ }.get(status)
512
+ if hours is not None:
513
+ return hours * 3600 if hours > 0 else None
514
+ if status == "icebox":
515
+ return policy.icebox_stale_days * 86400 if policy.icebox_stale_days > 0 else None
516
+ return None
517
+
518
+
519
+ def _flag_stale(card: dict, config) -> dict:
520
+ # Observability only — never mutates board state. Mutates the card dict in
521
+ # place and returns it so callers can map over a list.
522
+ if card.get("status") == "icebox" and card.get("completion_evidence"):
523
+ # Zombie: the work log claims finished work but the card never left
524
+ # icebox — surface it on every board render, independent of any SLA.
525
+ card["stale"] = True
526
+ card["stale_reason"] = (
527
+ "icebox card carries completion evidence (zombie) — "
528
+ "run cos_task_reconcile, then lifecycle it through complete"
529
+ )
530
+ return card
531
+ threshold = _sla_threshold_seconds(card.get("status", ""), config)
532
+ dwell = card.get("status_dwell_seconds")
533
+ if threshold is not None and dwell is not None and dwell > threshold:
534
+ card["stale"] = True
535
+ card["stale_reason"] = (
536
+ f"{card['status']} {card.get('status_dwell_human')} > SLA "
537
+ f"{_humanize_duration(threshold)}"
538
+ )
539
+ else:
540
+ card["stale"] = False
541
+ card["stale_reason"] = None
542
+ return card
543
+
544
+
545
+ _COMPLETION_EVIDENCE_RE = re.compile(
546
+ r"commit(?:ted)?\s+[0-9a-f]{7,40}"
547
+ r"|implemented\b.{0,40}\bverified"
548
+ r"|verified\b.{0,40}\bimplemented",
549
+ re.IGNORECASE,
550
+ )
551
+
552
+
553
+ def _completion_evidence(work_log_json: str | None) -> bool:
554
+ # Heuristic over the cached work-log lines: a linked commit sha or an
555
+ # "implemented … verified" claim is evidence of finished work. Used only
556
+ # for observability (zombie flag + reconcile triage), never for gating.
557
+ if not work_log_json:
558
+ return False
559
+ return bool(_COMPLETION_EVIDENCE_RE.search(str(work_log_json)))
560
+
561
+
562
+ def _last_log_line(work_log_json: str | None) -> str | None:
563
+ if not work_log_json:
564
+ return None
565
+ try:
566
+ lines = json.loads(work_log_json)
567
+ except json.JSONDecodeError:
568
+ return None
569
+ return lines[-1] if lines else None
570
+
571
+
572
+ def _agent_label(agent_session: str | None) -> str:
573
+ # Detection lives in _agent_runtime.detect_agent so cli/board_commands.py and
574
+ # this module share one code path; shell counterpart is core/hooks/cos-env.sh.
575
+ from ._agent_runtime import detect_agent
576
+
577
+ return detect_agent(agent_session)
578
+
579
+
580
+ def _resolve_attribution(agent_session: str | None) -> str | None:
581
+ # Without this, task_status_history.agent_session is NULL and the board UI
582
+ # renders the human "H" glyph for agent-driven creates. Reads $COS_SESSION_FILE
583
+ # (set by every adapter via session-context.sh), so the fix is adapter-agnostic.
584
+ from ._agent_runtime import resolve_agent_session
585
+
586
+ return resolve_agent_session(agent_session)
587
+
588
+
589
+ def _assign_guard(
590
+ file_path: Path | None,
591
+ agent_session: str | None,
592
+ force: bool,
593
+ ) -> str | None:
594
+ # Opt-in + backward-compatible: no `assignee:` field → movable by anyone.
595
+ # When set, only that session (or any session of the same agent) may move it;
596
+ # force=True or COS_ASSIGN_OVERRIDE=1 bypasses. Returns an error msg or None.
597
+ if force or os.environ.get("COS_ASSIGN_OVERRIDE") == "1":
598
+ return None
599
+ if file_path is None or not file_path.exists():
600
+ return None
601
+ try:
602
+ head = file_path.read_text(encoding="utf-8")[:2000]
603
+ except OSError:
604
+ return None
605
+ match = re.search(r"^assignee:[ \t]*(.+?)[ \t]*$", head, re.MULTILINE)
606
+ if not match:
607
+ return None
608
+ assignee = match.group(1).strip().strip('"').strip("'")
609
+ if assignee.lower() in ("", "any", "anyone", "unassigned", "null", "~"):
610
+ return None
611
+
612
+ from ._agent_runtime import detect_agent
613
+
614
+ mover = (agent_session or "").strip()
615
+ if assignee == mover:
616
+ return None
617
+ mover_agent = detect_agent(mover)
618
+ if mover_agent != "agent" and detect_agent(assignee) == mover_agent:
619
+ return None
620
+ return (
621
+ f"task is assigned to {assignee!r} — current mover is "
622
+ f"{mover or 'unattributed'!r}. Re-assign the task (edit its "
623
+ "`assignee:` frontmatter) or override with force=True / "
624
+ "COS_ASSIGN_OVERRIDE=1."
625
+ )
626
+
627
+
628
+ _BOARD_SELECT = (
629
+ "SELECT task_id, title, swimlane, kind, epic, labels_json, "
630
+ " status, priority, appetite, agent_session, work_log_last_5, "
631
+ " started_at, completed_at, "
632
+ # last_transition_at (row[13]): the most recent status-change time from
633
+ # history. Correlated subquery keeps the column appended LAST so existing
634
+ # positional readers (retro r[11]/r[12]) are unaffected. Powers the board
635
+ # time dimension (status_dwell_seconds) — RC5 of the 2026-06-05
636
+ # task-lifecycle review (TASK-210).
637
+ " (SELECT MAX(h.transitioned_at) FROM task_status_history h "
638
+ " WHERE h.task_id = tasks.task_id) AS last_transition_at "
639
+ "FROM tasks"
640
+ )
641
+
642
+
643
+ # ---------- cos_task_create ----------
644
+
645
+
646
+ @safe_tool
647
+ def cos_task_create(
648
+ conn: sqlite3.Connection,
649
+ *,
650
+ title: str,
651
+ swimlane: str,
652
+ kind: str,
653
+ priority: str = "P2",
654
+ appetite: str = "1d",
655
+ epic: str | None = None,
656
+ labels: list[str] | None = None,
657
+ outcome: str | None = None,
658
+ acceptance: str | None = None,
659
+ repro: str | None = None,
660
+ read_first: list[str] | None = None,
661
+ depends_on: list[str] | None = None,
662
+ status: str = "icebox",
663
+ ready: bool = False,
664
+ agent_session: str | None = None,
665
+ ) -> str:
666
+ """Create a new task MD file + sync into DB. Returns envelope."""
667
+ config = _current_config()
668
+ if config is not None and swimlane not in config.swimlane_ids:
669
+ return fail(
670
+ "validation",
671
+ f"swimlane {swimlane!r} not in config; valid: {sorted(config.swimlane_ids)}",
672
+ )
673
+ if kind not in KIND_ENUM:
674
+ return fail("validation", f"kind {kind!r} not in {sorted(KIND_ENUM)}")
675
+ if priority not in PRIORITY_ENUM:
676
+ return fail("validation", f"priority {priority!r} not in {sorted(PRIORITY_ENUM)}")
677
+ if not APPETITE_RE.match(appetite):
678
+ return fail("validation", f"appetite {appetite!r} bad shape")
679
+ if status not in STATUS_ENUM:
680
+ return fail("validation", f"status {status!r} not in {sorted(STATUS_ENUM)}")
681
+
682
+ bad_deps = [d for d in (depends_on or []) if not TASK_ID_FORMAT_RE.match(str(d))]
683
+ if bad_deps:
684
+ return fail(
685
+ "validation",
686
+ f"depends_on entries not TASK-NNN shaped: {bad_deps} — the cycle "
687
+ "detector and dependents queries match ids literally",
688
+ )
689
+
690
+ labels = list(labels or [])
691
+ for lbl in labels:
692
+ if lbl in KIND_ENUM:
693
+ return fail(
694
+ "validation",
695
+ f"label {lbl!r} collides with KIND_ENUM — use kind, not labels",
696
+ )
697
+ # `ready=True` is the one-shot path: create a groomed task already
698
+ # marked pullable, so the require_ready_label gate passes without a
699
+ # separate cos_task_ready call.
700
+ if ready and READY_LABEL not in labels:
701
+ labels.append(READY_LABEL)
702
+
703
+ # Force Definition of Ready when creating directly into in_progress —
704
+ # parity with the icebox→in_progress transition gate (workflow.transition),
705
+ # which the create-path otherwise bypasses. Validate BEFORE allocating a
706
+ # TASK id so a rejected create never burns an id. Lean capture into
707
+ # icebox/emergency stays unrestricted; complete stays a retro escape.
708
+ if status == "in_progress":
709
+ preview_rf = "\n".join(f"- {p}" for p in (read_first or ["(no doc yet — exploratory)"]))
710
+ preview_body = _render_kind_aware_body(
711
+ task_id="TASK-PENDING",
712
+ title=title,
713
+ kind=kind,
714
+ outcome=outcome,
715
+ read_first_block=preview_rf,
716
+ acceptance=acceptance,
717
+ )
718
+ try:
719
+ from board_os.transition_gates import GatesConfigError, load_gates_config
720
+ from board_os.transition_gates_validator import (
721
+ validate_transition as _gate_validate,
722
+ )
723
+
724
+ gate = _gate_validate(
725
+ task_id="TASK-PENDING",
726
+ kind=kind,
727
+ body=preview_body,
728
+ new_status="in_progress",
729
+ config=load_gates_config(),
730
+ override_reason=os.environ.get("COS_OVERRIDE_REASON"),
731
+ override_actor=os.environ.get("COS_AGENT") or agent_session,
732
+ )
733
+ except GatesConfigError:
734
+ gate = None
735
+ if gate is not None and gate.blocked:
736
+ return fail(
737
+ "validation",
738
+ "cannot create directly into in_progress — Definition of Ready not met: "
739
+ + "; ".join(f"[{m.code}] {m.message}" for m in gate.messages)
740
+ + ". Fix: create into icebox, fill Outcome + Acceptance, mark ready, "
741
+ "then cos_task_start — or pass outcome= and acceptance= to one-shot it.",
742
+ )
743
+
744
+ # Auto-attribute the create event to the running agent's session
745
+ # when the caller didn't pass one. Skipping this leaves NULL in
746
+ # task_status_history.agent_session, which the board UI maps to
747
+ # the green "H" glyph — making MCP-driven creates look human-led.
748
+ agent_session = _resolve_attribution(agent_session)
749
+
750
+ project_root = _project_root()
751
+ tasks_dir = project_root / "docs" / "tasks"
752
+ tasks_dir.mkdir(parents=True, exist_ok=True)
753
+
754
+ try:
755
+ task_id = _next_task_id(conn, project_root)
756
+ except sqlite3.OperationalError as exc:
757
+ return fail(
758
+ "unavailable",
759
+ f"task-id allocation failed under DB lock contention: {exc} — retry the create",
760
+ )
761
+ slug = _slugify(title)
762
+ file_path = tasks_dir / f"{task_id}-{slug}.md"
763
+ if file_path.exists():
764
+ return fail("validation", f"file already exists: {file_path.name}")
765
+
766
+ today = datetime.utcnow().strftime("%Y-%m-%d")
767
+ fm = {
768
+ "id": task_id,
769
+ "title": title,
770
+ "swimlane": swimlane,
771
+ "kind": kind,
772
+ "epic": epic,
773
+ "labels": labels,
774
+ "status": status,
775
+ "priority": priority,
776
+ "appetite": appetite,
777
+ "created": today,
778
+ # Task-lifecycle fix: when a task is created
779
+ # directly into `in_progress`, stamp started + agent_session
780
+ # so YAML and DB agree. F17b: narrowed to `in_progress` only
781
+ # to match `workflow.transition`'s semantics — testing/emergency
782
+ # are reached via transition, not create-path, so stamping them
783
+ # at create would diverge from the transition convention.
784
+ # `completed` stays stamp-on-create because creating a task
785
+ # already-complete is a legitimate retro entry.
786
+ "started": today if status == "in_progress" else None,
787
+ "completed": today if status == "complete" else None,
788
+ "agent_session": agent_session if status == "in_progress" else None,
789
+ "depends_on": depends_on or [],
790
+ "blocked_by": [],
791
+ "references": [],
792
+ }
793
+ frontmatter = _render_lean_frontmatter(fm)
794
+
795
+ rf_lines = "\n".join(f"- {p}" for p in (read_first or ["(no doc yet — exploratory)"]))
796
+ body = _render_kind_aware_body(
797
+ task_id=task_id,
798
+ title=title,
799
+ kind=kind,
800
+ outcome=outcome,
801
+ read_first_block=rf_lines,
802
+ acceptance=acceptance,
803
+ repro=repro,
804
+ )
805
+ file_path.write_text(frontmatter + body, encoding="utf-8")
806
+
807
+ sync_one(conn, file_path, project_root=project_root)
808
+
809
+ # Emit a canonical creation event into task_status_history so the
810
+ # live-agents panel and retro queries can attribute WHO created the
811
+ # task and WHEN. Shape: old_status=NULL signals "created" to the
812
+ # stream renderer (see core/web/ui/.../useBoardStream.ts). Any
813
+ # sqlite error here must NOT fail the create — the task is already
814
+ # on disk + synced; history is an audit signal, not a gate.
815
+ try:
816
+ import time as _time
817
+
818
+ # old_status uses '' (empty string) as the "nothing to transition
819
+ # from" sentinel — the task_status_history.old_status column is
820
+ # NOT NULL (migration v13 schema). The stream renderer normalises
821
+ # '' back to null/creation in both history + SSE paths so the UI
822
+ # distinguishes "create" from "move" without a schema migration.
823
+ conn.execute(
824
+ """
825
+ INSERT INTO task_status_history
826
+ (task_id, old_status, new_status, agent_session,
827
+ reason, transitioned_at)
828
+ VALUES (?, '', ?, ?, ?, ?)
829
+ """,
830
+ (task_id, status, agent_session, "created", int(_time.time())),
831
+ )
832
+ conn.commit()
833
+ except sqlite3.Error as exc:
834
+ import logging as _logging
835
+
836
+ _logging.getLogger("coding_os.board_os").debug(
837
+ "create-history insert failed for %s: %s",
838
+ task_id,
839
+ exc,
840
+ )
841
+ # Also persist the agent session onto the tasks row so the UI
842
+ # can still attribute this task even without a history row.
843
+ try:
844
+ conn.execute(
845
+ "UPDATE tasks SET agent_session = COALESCE(?, agent_session) WHERE task_id = ?",
846
+ (agent_session, task_id),
847
+ )
848
+ conn.commit()
849
+ except sqlite3.Error as exc2:
850
+ _logging.getLogger("coding_os.board_os").debug(
851
+ "create-history agent_session fallback failed: %s",
852
+ exc2,
853
+ )
854
+ else:
855
+ # History row landed; also stamp the tasks row so board_list can
856
+ # render the creator badge without re-joining history.
857
+ try:
858
+ conn.execute(
859
+ "UPDATE tasks SET agent_session = COALESCE(?, agent_session) WHERE task_id = ?",
860
+ (agent_session, task_id),
861
+ )
862
+ conn.commit()
863
+ except sqlite3.Error as exc_stamp:
864
+ import logging as _logging
865
+
866
+ _logging.getLogger("coding_os.board_os").debug(
867
+ "create stamp on tasks.agent_session failed: %s",
868
+ exc_stamp,
869
+ )
870
+
871
+ # Create-time DoR echo — the same validator the ready/start gates run,
872
+ # surfaced NOW so a placeholder create is never silently "fine" and only
873
+ # discovered turns later at task-start. Warn-only: lean capture into
874
+ # icebox stays allowed; the gaps just ride the envelope.
875
+ dor_gaps, _ = _ready_dor_check(file_path, agent_session)
876
+ is_ready = READY_LABEL in labels
877
+ # `ready` must be HONEST: a block-severity gap means the task cannot
878
+ # leave icebox, regardless of the ready label (the old shape echoed
879
+ # ready=true next to block gaps — a self-contradiction).
880
+ has_block_gap = any(g.get("severity") == "block" for g in dor_gaps)
881
+ dor = {"ready": is_ready and not has_block_gap, "label_ready": is_ready, "gaps": dor_gaps}
882
+ if dor_gaps or not is_ready:
883
+ fixes = []
884
+ if dor_gaps:
885
+ fixes.append("fill the flagged sections (outcome=/acceptance=/repro=/read_first=)")
886
+ if not is_ready:
887
+ fixes.append(f"mark pullable: cos task-ready {task_id} (or create with ready=True)")
888
+ dor["fix"] = "; ".join(fixes)
889
+
890
+ return ok(
891
+ {
892
+ "task_id": task_id,
893
+ "file_path": str(file_path.relative_to(project_root)),
894
+ "swimlane": swimlane,
895
+ "kind": kind,
896
+ "status": status,
897
+ "dor": dor,
898
+ "next_steps": _next_steps_for_kind(kind),
899
+ },
900
+ meta={"layer": "tasks", "source": "board_os.cos_task_create"},
901
+ )
902
+
903
+
904
+ # ---------- cos_task_board ----------
905
+
906
+ # TASK-209: tiny safety margin below the budget — the probe mirrors the real
907
+ # envelope closely, so only a few bytes of slack are needed.
908
+ _BOARD_BUDGET_HEADROOM = 256
909
+
910
+
911
+ def _cap_board_to_budget(cards: list[dict], *, budget: int) -> tuple[list[dict], bool]:
912
+ # Drop the lowest-priority cards (P9 last) until the serialized board body
913
+ # fits `budget` (agent path only — the browser opts out via apply_budget=False).
914
+ # The kept set preserves original display order. `cards` is outside the
915
+ # envelope trim ladder, so without this cap a large board produced an
916
+ # unshrinkable >32KB envelope (TASK-209). Returns (kept, capped). The board no
917
+ # longer emits a duplicate `grouped` view (TASK-259) — clients group cards by
918
+ # swimlane×status themselves, halving the payload on both the agent and wire.
919
+ def _fits(subset: list[dict]) -> bool:
920
+ # Mirror ok(): pretty-printed full envelope, measured with the same
921
+ # _budget_size the trimmer uses (inflates non-Latin), so the cap holds
922
+ # for Persian/Arabic titles too — not just ASCII.
923
+ probe = json.dumps(
924
+ {
925
+ "ok": True,
926
+ "data": {
927
+ "cards": subset,
928
+ "count": len(subset),
929
+ "total_count": len(cards),
930
+ "truncated": True,
931
+ "wip": {"counts": {}, "caps": {}, "violations": []},
932
+ "meta": {
933
+ "layer": "tasks",
934
+ "source": "board_os.cos_task_board",
935
+ "tokens_estimated": 0,
936
+ "truncated": True,
937
+ },
938
+ },
939
+ },
940
+ indent=2,
941
+ default=str,
942
+ )
943
+ return _budget_size(probe) <= budget
944
+
945
+ if _fits(cards):
946
+ return cards, False
947
+
948
+ total = len(cards)
949
+ ranked = sorted(range(total), key=lambda i: (str(cards[i].get("priority", "P9")), i))
950
+ keep = total
951
+ while keep > 0:
952
+ keep = keep - 1 if keep <= 12 else int(keep * 0.85)
953
+ keep_idx = set(ranked[:keep])
954
+ subset = [c for i, c in enumerate(cards) if i in keep_idx]
955
+ if _fits(subset):
956
+ return subset, True
957
+ return [], True
958
+
959
+
960
+ # Columns whose row count grows without bound (finished work accumulates
961
+ # forever). These are keyset-paginated; every other column is "active" and
962
+ # returned in full up to a safety cap. TASK-223.
963
+ _PAGED_STATUSES = ("complete", "archive")
964
+ # Safety cap on each active board read so even a runaway icebox can't OOM the
965
+ # response. Honest truncation is signalled via columns["_active"].
966
+ _ACTIVE_COLUMN_HARD_MAX = 2000
967
+ # Hard ceiling on one keyset page of a paged column.
968
+ _PAGE_SIZE_HARD_MAX = 200
969
+
970
+
971
+ # Cursor schema version — bump when the keyset key changes. A versioned
972
+ # cursor from an older schema decodes to None (page 1) instead of silently
973
+ # slicing the wrong key (TASK-399).
974
+ _BOARD_CURSOR_VERSION = "v1"
975
+
976
+
977
+ def _encode_board_cursor(completed_at: int | None, task_id: str) -> str:
978
+ raw = json.dumps([_BOARD_CURSOR_VERSION, completed_at, task_id]).encode("utf-8")
979
+ return base64.urlsafe_b64encode(raw).decode("ascii")
980
+
981
+
982
+ def _decode_board_cursor(cursor: str | None) -> tuple[int | None, str] | None:
983
+ if not cursor:
984
+ return None
985
+ try:
986
+ version, completed_at, task_id = json.loads(
987
+ base64.urlsafe_b64decode(cursor.encode("ascii"))
988
+ )
989
+ if version != _BOARD_CURSOR_VERSION:
990
+ return None
991
+ return completed_at, str(task_id)
992
+ except Exception:
993
+ return None
994
+
995
+
996
+ def _keyset_filter(cursor: str | None) -> tuple[str, list]:
997
+ # Rows strictly AFTER the cursor in (completed_at DESC, task_id DESC) order;
998
+ # NULL completed_at (archive rows) sort last.
999
+ decoded = _decode_board_cursor(cursor)
1000
+ if decoded is None:
1001
+ return "", []
1002
+ completed_at, task_id = decoded
1003
+ if completed_at is None:
1004
+ # Inside the NULL-completed tail (archive): tiebreak by task_id only.
1005
+ return "completed_at IS NULL AND task_id < ?", [task_id]
1006
+ # Lower completed_at, or same completed_at + lower task_id, or the NULL tail.
1007
+ return (
1008
+ "(completed_at < ? OR (completed_at = ? AND task_id < ?) OR completed_at IS NULL)",
1009
+ [completed_at, completed_at, task_id],
1010
+ )
1011
+
1012
+
1013
+ def _keyset_column_page(
1014
+ conn: sqlite3.Connection,
1015
+ status: str,
1016
+ base_clauses: list[str],
1017
+ base_params: list,
1018
+ cursor: str | None,
1019
+ page_size: int,
1020
+ config,
1021
+ ) -> tuple[list[dict], str | None, int]:
1022
+ page_size = max(1, min(int(page_size), _PAGE_SIZE_HARD_MAX))
1023
+ col_clauses = list(base_clauses) + ["status = ?"]
1024
+ col_params = list(base_params) + [status]
1025
+
1026
+ total = conn.execute(
1027
+ f"SELECT COUNT(*) FROM tasks WHERE {' AND '.join(col_clauses)}", col_params
1028
+ ).fetchone()[0]
1029
+
1030
+ ks_clause, ks_params = _keyset_filter(cursor)
1031
+ where = " AND ".join(col_clauses + ([ks_clause] if ks_clause else []))
1032
+ query = f"{_BOARD_SELECT} WHERE {where} ORDER BY completed_at DESC, task_id DESC LIMIT ?"
1033
+ rows = conn.execute(query, col_params + ks_params + [page_size + 1]).fetchall()
1034
+ has_more = len(rows) > page_size
1035
+ rows = rows[:page_size]
1036
+ cards = [_flag_stale(_task_card(r), config) for r in rows]
1037
+
1038
+ next_cursor = None
1039
+ if has_more and cards:
1040
+ # Read the keyset key from the shaped card (named fields) instead of
1041
+ # positional row indexes — a _BOARD_SELECT column shuffle can no
1042
+ # longer silently corrupt pagination.
1043
+ last = cards[-1]
1044
+ next_cursor = _encode_board_cursor(last.get("completed_at"), last["id"])
1045
+ return cards, next_cursor, total
1046
+
1047
+
1048
+ @safe_tool
1049
+ def cos_task_board(
1050
+ conn: sqlite3.Connection,
1051
+ *,
1052
+ swimlane: str | None = None,
1053
+ kind: str | None = None,
1054
+ epic: str | None = None,
1055
+ status_filter: list[str] | None = None,
1056
+ include_archive: bool = False,
1057
+ limit: int = 50,
1058
+ page_size: int = 50,
1059
+ cursor: str | None = None,
1060
+ apply_budget: bool = True,
1061
+ ) -> str:
1062
+ config = _current_config()
1063
+
1064
+ base_clauses: list[str] = []
1065
+ base_params: list = []
1066
+ for col, val in (("swimlane", swimlane), ("kind", kind), ("epic", epic)):
1067
+ if val:
1068
+ base_clauses.append(f"{col} = ?")
1069
+ base_params.append(val)
1070
+
1071
+ # Split requested columns into ACTIVE (returned in full, capped) and PAGED
1072
+ # (complete/archive — keyset-paginated so a 50K-deep column never floods the
1073
+ # payload). Supersedes the interim apply_budget return-all (TASK-220/223).
1074
+ paged_set = set(_PAGED_STATUSES)
1075
+ if status_filter:
1076
+ active_statuses = [s for s in status_filter if s not in paged_set]
1077
+ paged_statuses = [s for s in status_filter if s in paged_set]
1078
+ want_active = bool(active_statuses)
1079
+ else:
1080
+ active_statuses = None # all non-paged statuses, single query
1081
+ paged_statuses = list(_PAGED_STATUSES) if include_archive else []
1082
+ want_active = True
1083
+
1084
+ columns_meta: dict = {}
1085
+ cards: list[dict] = []
1086
+
1087
+ # ---- Active columns: full, bounded by a safety cap ----
1088
+ if want_active:
1089
+ active_cap = max(1, min(int(limit), _ACTIVE_COLUMN_HARD_MAX))
1090
+ a_clauses = list(base_clauses)
1091
+ a_params = list(base_params)
1092
+ if active_statuses:
1093
+ ph = ",".join("?" for _ in active_statuses)
1094
+ a_clauses.append(f"status IN ({ph})")
1095
+ a_params.extend(active_statuses)
1096
+ else:
1097
+ a_clauses.append("status NOT IN ('complete', 'archive')")
1098
+ where = f"WHERE {' AND '.join(a_clauses)}" if a_clauses else ""
1099
+ query = f"{_BOARD_SELECT} {where} ORDER BY swimlane, status, priority LIMIT ?"
1100
+ a_rows = conn.execute(query, a_params + [active_cap + 1]).fetchall()
1101
+ active_truncated = len(a_rows) > active_cap
1102
+ a_rows = a_rows[:active_cap]
1103
+ cards.extend(_flag_stale(_task_card(r), config) for r in a_rows)
1104
+ if active_truncated:
1105
+ columns_meta["_active"] = {"truncated": True, "cap": active_cap}
1106
+
1107
+ # ---- Paged columns: one keyset page each (cursor + per-column total) ----
1108
+ for status in paged_statuses:
1109
+ page_cards, next_cursor, col_total = _keyset_column_page(
1110
+ conn, status, base_clauses, base_params, cursor, page_size, config
1111
+ )
1112
+ cards.extend(page_cards)
1113
+ columns_meta[status] = {
1114
+ "total_count": col_total,
1115
+ "returned": len(page_cards),
1116
+ "next_cursor": next_cursor,
1117
+ "truncated": next_cursor is not None,
1118
+ }
1119
+
1120
+ # Per-column queries make the payload inherently bounded. apply_budget still
1121
+ # applies the 32KB agent-context cap (a board read must never flood an
1122
+ # agent's context); the browser passes apply_budget=False and is safe now
1123
+ # that no single column returns more than its cap/page.
1124
+ total_count = len(cards)
1125
+ if apply_budget:
1126
+ # Account for the columns meta (not in _cap_board_to_budget's probe) so
1127
+ # the 32KB agent-envelope guarantee (TASK-209) holds even with paging.
1128
+ columns_overhead = len(json.dumps(columns_meta, default=str)) if columns_meta else 0
1129
+ cards, board_truncated = _cap_board_to_budget(
1130
+ cards, budget=TOKEN_BUDGET_CHARS - _BOARD_BUDGET_HEADROOM - columns_overhead
1131
+ )
1132
+ else:
1133
+ board_truncated = False
1134
+
1135
+ wip_state = None
1136
+ if config is not None:
1137
+ state = check_wip(conn, config)
1138
+ wip_state = {
1139
+ "counts": state.counts,
1140
+ "caps": state.caps,
1141
+ "violations": list(state.violations),
1142
+ }
1143
+
1144
+ return ok(
1145
+ {
1146
+ "cards": cards,
1147
+ "columns": columns_meta,
1148
+ "count": len(cards),
1149
+ "total_count": total_count,
1150
+ "truncated": board_truncated,
1151
+ "wip": wip_state,
1152
+ },
1153
+ meta={"layer": "tasks", "source": "board_os.cos_task_board"},
1154
+ # Browser path (apply_budget=False) opts out of the 32KB agent cap in
1155
+ # ok() too — not just _cap_board_to_budget above — so a large board never
1156
+ # trips envelope_unshrinkable on the wire. The agent path keeps the cap.
1157
+ apply_budget=apply_budget,
1158
+ )
1159
+
1160
+
1161
+ # ---------- cos_task_show ----------
1162
+
1163
+
1164
+ @safe_tool
1165
+ def cos_task_show(
1166
+ conn: sqlite3.Connection,
1167
+ *,
1168
+ task_id: str,
1169
+ include_body: bool = True,
1170
+ ) -> str:
1171
+ row = conn.execute(
1172
+ "SELECT task_id, title, status, swimlane, kind, priority, appetite, "
1173
+ "file_path, epic, labels_json, agent_session, started_at, completed_at "
1174
+ "FROM tasks WHERE task_id = ?",
1175
+ (task_id,),
1176
+ ).fetchone()
1177
+ if row is None:
1178
+ return fail("not_found", f"task {task_id} not found")
1179
+ try:
1180
+ labels = json.loads(row[9] or "[]")
1181
+ except (TypeError, json.JSONDecodeError):
1182
+ labels = []
1183
+ data = {
1184
+ "id": row[0],
1185
+ "title": row[1],
1186
+ "status": row[2],
1187
+ "swimlane": row[3],
1188
+ "kind": row[4],
1189
+ "priority": row[5],
1190
+ "appetite": row[6],
1191
+ "file_path": row[7],
1192
+ # Fields the DB already stores but the tool used to drop, forcing callers
1193
+ # to re-parse the raw body. depends_on/blocked_by/references stay
1194
+ # frontmatter-only and remain available in `body`.
1195
+ "epic": row[8],
1196
+ "labels": labels,
1197
+ "agent_session": row[10],
1198
+ "started_at": row[11],
1199
+ "completed_at": row[12],
1200
+ "body": None,
1201
+ }
1202
+ if include_body and row[7]:
1203
+ full = _project_root() / row[7]
1204
+ if full.exists():
1205
+ data["body"] = full.read_text(encoding="utf-8")
1206
+ return ok(data, meta={"layer": "tasks", "source": "board_os.cos_task_show"})
1207
+
1208
+
1209
+ # ---------- cos_task_move ----------
1210
+
1211
+
1212
+ def _record_completion_outcome_safe(conn: sqlite3.Connection, task_id: str) -> None:
1213
+ # Fire-and-forget: feed an MCP-driven completion into the learning loop,
1214
+ # mirroring the CLI task-done path. Without this, tasks closed via
1215
+ # cos_task_move never produced a task_outcome row.
1216
+ try:
1217
+ from thinking_os.record_outcome import record_outcome
1218
+
1219
+ krow = conn.execute("SELECT kind FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
1220
+ kind = (krow[0] if krow else "") or "feature"
1221
+ ttype = {
1222
+ "bug": "fix",
1223
+ "feature": "feat",
1224
+ "refactor": "refactor",
1225
+ "docs": "docs",
1226
+ "test": "test",
1227
+ "chore": "infra",
1228
+ "spike": "spike",
1229
+ "security": "security",
1230
+ }.get(kind, "feat")
1231
+ db_path = os.environ.get(
1232
+ "COS_DB_PATH", str(_project_root() / ".coding-os" / "coding-os.db")
1233
+ )
1234
+ record_outcome(task_id=task_id, task_type=ttype, outcome="success", db_path=db_path)
1235
+ except Exception as exc:
1236
+ logger.debug("MCP completion outcome failed for %s: %s", task_id, exc)
1237
+
1238
+
1239
+ def _close_learning_loop_safe(conn: sqlite3.Connection) -> None:
1240
+ # Fire-and-forget: validate the lessons surfaced this task, mirroring the
1241
+ # task-done Bash hook (remind-learn-validate.sh) which NEVER fires on an MCP
1242
+ # tool call — the gap that left pattern_validations empty and every pattern
1243
+ # stuck below the Trusted tier. The Bash hook owns closure whenever a shell
1244
+ # ran `cos task-move` (COS_PANEL_DIR is set there); a direct MCP call runs in
1245
+ # the long-lived server (no COS_PANEL_DIR, no Bash hook), so ONLY there does
1246
+ # this path close the loop — no double-validation.
1247
+ if os.environ.get("COS_PANEL_DIR"):
1248
+ return
1249
+ try:
1250
+ from thinking_os.gate_marker import newest_panel_gate
1251
+ from thinking_os.tools.learning import validate_surfaced_lessons
1252
+
1253
+ gate = newest_panel_gate()
1254
+ if gate is None:
1255
+ return
1256
+ panel_dir = gate.parent
1257
+ suggestions = panel_dir / ".learn-suggestions"
1258
+ if not suggestions.exists() or suggestions.stat().st_size == 0:
1259
+ return
1260
+ sid_file = panel_dir / "session-id"
1261
+ session_id = sid_file.read_text(encoding="utf-8").strip() if sid_file.exists() else ""
1262
+ if not session_id:
1263
+ return
1264
+ validate_surfaced_lessons(conn, session_id=session_id, suggestions_path=str(suggestions))
1265
+ suggestions.write_text("", encoding="utf-8") # per-task boundary, like the hook
1266
+ except Exception as exc:
1267
+ logger.debug("MCP learning-loop closure failed: %s", exc)
1268
+
1269
+
1270
+ _TERMINAL_DEP_STATES = ("archive",)
1271
+
1272
+
1273
+ def cascade_ready_dependents(
1274
+ conn: sqlite3.Connection,
1275
+ completed_task_id: str,
1276
+ *,
1277
+ agent_session: str | None = None,
1278
+ ) -> dict[str, list]:
1279
+ """Auto-ready every dependent of `completed_task_id` now unblocked + DoR-complete.
1280
+
1281
+ Run after a task transitions to `complete`. Each dependent is classified:
1282
+ `readied` (all deps complete AND body DoR met — the ready label is added,
1283
+ moving blocked→icebox first), `needs_authoring` (all deps complete but the
1284
+ body DoR is incomplete — surfaced, not silently hidden), or `still_blocked`
1285
+ (another dep is open, or a dep is archived/cancelled — left blocked with a
1286
+ reason instead of hanging). Already-ready or active dependents are skipped.
1287
+ """
1288
+ report: dict[str, list] = {"readied": [], "needs_authoring": [], "still_blocked": []}
1289
+ project_root = _project_root()
1290
+ for dependent_id in dependents_of(conn, completed_task_id):
1291
+ row = conn.execute(
1292
+ "SELECT status, file_path, labels_json FROM tasks WHERE task_id = ?",
1293
+ (dependent_id,),
1294
+ ).fetchone()
1295
+ if row is None:
1296
+ continue
1297
+ status = str(row[0])
1298
+ # Only backlog cards are cascade targets; an active/done card is the
1299
+ # owning session's concern, never auto-mutated here.
1300
+ if status not in ("icebox", "blocked"):
1301
+ continue
1302
+ if READY_LABEL in _labels_list_from_json(row[2]):
1303
+ continue
1304
+
1305
+ pending = incomplete_dependencies(conn, dependent_id)
1306
+ if pending:
1307
+ terminal = [
1308
+ dep
1309
+ for dep in pending
1310
+ if (
1311
+ conn.execute("SELECT status FROM tasks WHERE task_id = ?", (dep,)).fetchone()
1312
+ or (None,)
1313
+ )[0]
1314
+ in _TERMINAL_DEP_STATES
1315
+ ]
1316
+ reason = (
1317
+ f"dependency terminal-failed (archived): {', '.join(terminal)}"
1318
+ if terminal
1319
+ else f"still waiting on: {', '.join(pending)}"
1320
+ )
1321
+ report["still_blocked"].append({"task_id": dependent_id, "reason": reason})
1322
+ continue
1323
+
1324
+ # All deps complete. Gate on the body DoR before auto-readying so the
1325
+ # cascade never marks an unauthored stub pullable.
1326
+ file_path = project_root / row[1] if row[1] else None
1327
+ dor_gaps: list[dict[str, str]] = []
1328
+ if file_path is not None and file_path.exists():
1329
+ dor_gaps, _ = _ready_dor_check(file_path, agent_session)
1330
+ if dor_gaps:
1331
+ report["needs_authoring"].append({"task_id": dependent_id, "dor": dor_gaps})
1332
+ continue
1333
+
1334
+ # blocked must return to icebox before it can carry the ready label and
1335
+ # be pulled (blocked→in_progress skips the icebox ready gate otherwise).
1336
+ if status == "blocked":
1337
+ move_env = json.loads(
1338
+ cos_task_move(conn, task_id=dependent_id, to="icebox", agent_session=agent_session)
1339
+ )
1340
+ if not move_env.get("ok"):
1341
+ report["still_blocked"].append(
1342
+ {"task_id": dependent_id, "reason": "could not unblock to icebox"}
1343
+ )
1344
+ continue
1345
+ ready_env = json.loads(
1346
+ cos_task_ready(conn, task_id=dependent_id, agent_session=agent_session)
1347
+ )
1348
+ if ready_env.get("ok"):
1349
+ report["readied"].append(dependent_id)
1350
+ else:
1351
+ report["still_blocked"].append(
1352
+ {"task_id": dependent_id, "reason": "ready label add failed"}
1353
+ )
1354
+ return report
1355
+
1356
+
1357
+ def _cascade_ready_dependents_safe(
1358
+ conn: sqlite3.Connection, task_id: str, agent_session: str | None
1359
+ ) -> dict[str, list]:
1360
+ # Fire-and-forget: the completion itself already committed; a cascade
1361
+ # failure must never turn a successful close into an error.
1362
+ try:
1363
+ return cascade_ready_dependents(conn, task_id, agent_session=agent_session)
1364
+ except Exception as exc: # noqa: BLE001 - fire-and-forget
1365
+ logger.debug("dependent cascade after %s complete failed: %s", task_id, exc)
1366
+ return {"readied": [], "needs_authoring": [], "still_blocked": []}
1367
+
1368
+
1369
+ @safe_tool
1370
+ def _auto_reclaim_zombies_safe(conn: sqlite3.Connection) -> None:
1371
+ """Best-effort zombie reclaim run before an in_progress pull. Frees idle
1372
+ in_progress tasks of inactive sessions so the board self-heals without a
1373
+ manual `cos task-reclaim`. Never raises (cos_task_reclaim is @safe_tool)."""
1374
+ try:
1375
+ cos_task_reclaim(conn)
1376
+ except Exception as exc: # pragma: no cover - defensive
1377
+ logger.debug("auto-reclaim before start skipped: %s", exc)
1378
+
1379
+
1380
+ @safe_tool
1381
+ def cos_task_move(
1382
+ conn: sqlite3.Connection,
1383
+ *,
1384
+ task_id: str,
1385
+ to: str,
1386
+ reason: str | None = None,
1387
+ bypass_wip: bool = False,
1388
+ bypass_gates: bool = False,
1389
+ force: bool = False,
1390
+ agent_session: str | None = None,
1391
+ ) -> str:
1392
+ config = _current_config()
1393
+
1394
+ row = conn.execute(
1395
+ "SELECT file_path FROM tasks WHERE task_id = ?",
1396
+ (task_id,),
1397
+ ).fetchone()
1398
+ file_path = None
1399
+ if row and row[0]:
1400
+ candidate = _project_root() / row[0]
1401
+ if candidate.exists():
1402
+ file_path = candidate
1403
+ elif to == "complete" and not bypass_gates and not force:
1404
+ # Fail CLOSED when the file is gone: the DoD gate can't run, and a
1405
+ # silent skip would close an unverifiable task (TASK-532).
1406
+ return fail(
1407
+ "validation",
1408
+ f"task file not found — cannot verify DoD: {row[0]}. Re-materialize "
1409
+ "the task file before closing (it desynced from the DB).",
1410
+ )
1411
+
1412
+ agent_session = _resolve_attribution(agent_session)
1413
+ guard = _assign_guard(file_path, agent_session, force)
1414
+ if guard is not None:
1415
+ return fail("validation", guard)
1416
+
1417
+ # Free zombie in_progress of dead/idle sessions before a pull, so a live
1418
+ # agent isn't blocked by a crashed peer and the board self-heals without a
1419
+ # manual `cos task-reclaim`. Conservative — only idle + owner-inactive
1420
+ # tasks qualify (see cos_task_reclaim). Best-effort; never blocks the move.
1421
+ if to == "in_progress" and not bypass_wip and not force:
1422
+ _auto_reclaim_zombies_safe(conn)
1423
+
1424
+ result = transition(
1425
+ conn,
1426
+ task_id,
1427
+ to,
1428
+ reason=reason,
1429
+ agent_session=agent_session,
1430
+ bypass_wip=bypass_wip,
1431
+ bypass_gates=bypass_gates,
1432
+ force=force,
1433
+ config=config,
1434
+ file_path=file_path,
1435
+ )
1436
+ if not result.ok:
1437
+ return fail(result.error_category or "internal", result.error or "transition failed")
1438
+
1439
+ data: dict = {
1440
+ "task_id": result.task_id,
1441
+ "previous_status": result.previous_status,
1442
+ "new_status": result.new_status,
1443
+ "warnings": list(result.warnings),
1444
+ "wip": result.wip_state,
1445
+ }
1446
+ if result.new_status == "complete":
1447
+ _record_completion_outcome_safe(conn, task_id)
1448
+ _close_learning_loop_safe(conn)
1449
+ cascade = _cascade_ready_dependents_safe(conn, task_id, agent_session)
1450
+ if any(cascade.values()):
1451
+ data["cascade"] = cascade
1452
+
1453
+ return ok(data, meta={"layer": "tasks", "source": "board_os.cos_task_move"})
1454
+
1455
+
1456
+ # ---------- cos_task_reposition ----------
1457
+
1458
+
1459
+ @safe_tool
1460
+ def cos_task_reposition(
1461
+ conn: sqlite3.Connection,
1462
+ *,
1463
+ task_id: str,
1464
+ swimlane: str | None = None,
1465
+ to: str | None = None,
1466
+ reason: str | None = None,
1467
+ bypass_wip: bool = False,
1468
+ force: bool = False,
1469
+ agent_session: str | None = None,
1470
+ ) -> str:
1471
+ """Change task status and/or swimlane (YAML frontmatter + sync).
1472
+
1473
+ Status changes use the same state machine + WIP rules as ``cos_task_move``.
1474
+ Swimlane-only changes patch the task MD file then ``sync_one``.
1475
+ When both are supplied, status transition runs first, then swimlane patch.
1476
+ """
1477
+ to_eff = (to or "").strip() or None
1478
+ swim_eff = (swimlane or "").strip() or None
1479
+ if not to_eff and not swim_eff:
1480
+ return fail(
1481
+ "validation",
1482
+ "at least one of `to` (status) or `swimlane` must be provided",
1483
+ )
1484
+
1485
+ row = conn.execute(
1486
+ "SELECT status, swimlane, file_path FROM tasks WHERE task_id = ?",
1487
+ (task_id,),
1488
+ ).fetchone()
1489
+ if row is None:
1490
+ return fail("not_found", f"task {task_id} not found")
1491
+
1492
+ current_status = str(row[0])
1493
+ cur_sl_raw = row[1]
1494
+ cur_sl = (str(cur_sl_raw).strip() if cur_sl_raw else "") or ""
1495
+ rel_path = row[2]
1496
+ project_root = _project_root()
1497
+ file_path: Path | None = None
1498
+ if rel_path:
1499
+ candidate = project_root / rel_path
1500
+ if candidate.exists():
1501
+ file_path = candidate
1502
+
1503
+ config = _current_config()
1504
+ agent_session = _resolve_attribution(agent_session)
1505
+ guard = _assign_guard(file_path, agent_session, force)
1506
+ if guard is not None:
1507
+ return fail("validation", guard)
1508
+ if swim_eff is not None:
1509
+ if config is None:
1510
+ return fail(
1511
+ "unavailable",
1512
+ "scrumban-config.yaml not found — run `cos board-config --init`",
1513
+ )
1514
+ if swim_eff not in config.swimlane_ids:
1515
+ return fail(
1516
+ "validation",
1517
+ f"swimlane {swim_eff!r} not in config; valid: {sorted(config.swimlane_ids)}",
1518
+ )
1519
+
1520
+ wants_status = to_eff is not None and to_eff != current_status
1521
+ wants_swim = swim_eff is not None and swim_eff != cur_sl
1522
+
1523
+ if not wants_status and not wants_swim:
1524
+ return ok(
1525
+ {
1526
+ "task_id": task_id,
1527
+ "previous_status": current_status,
1528
+ "new_status": current_status,
1529
+ "previous_swimlane": cur_sl or None,
1530
+ "new_swimlane": cur_sl or None,
1531
+ "warnings": ["no-op (already at requested status and swimlane)"],
1532
+ },
1533
+ meta={"layer": "tasks", "source": "board_os.cos_task_reposition"},
1534
+ )
1535
+
1536
+ prev_status = current_status
1537
+ new_status = current_status
1538
+ warnings: list[str] = []
1539
+
1540
+ if wants_status:
1541
+ result = transition(
1542
+ conn,
1543
+ task_id,
1544
+ to_eff, # type: ignore[arg-type]
1545
+ reason=reason,
1546
+ agent_session=agent_session,
1547
+ bypass_wip=bypass_wip,
1548
+ force=force,
1549
+ config=config,
1550
+ file_path=file_path,
1551
+ )
1552
+ if not result.ok:
1553
+ return fail(
1554
+ result.error_category or "internal",
1555
+ result.error or "transition failed",
1556
+ )
1557
+ new_status = result.new_status
1558
+ warnings.extend(list(result.warnings))
1559
+ row2 = conn.execute(
1560
+ "SELECT swimlane FROM tasks WHERE task_id = ?",
1561
+ (task_id,),
1562
+ ).fetchone()
1563
+ cur_sl = (str(row2[0]).strip() if row2 and row2[0] else "") or ""
1564
+
1565
+ new_sl = cur_sl
1566
+ if wants_swim:
1567
+ if file_path is None:
1568
+ return fail(
1569
+ "unavailable",
1570
+ f"task {task_id} has no on-disk file — cannot change swimlane",
1571
+ )
1572
+ try:
1573
+ patch_task_frontmatter_scalars(file_path, {"swimlane": swim_eff})
1574
+ except (OSError, ValueError) as exc:
1575
+ return fail("validation", f"swimlane patch failed: {exc}")
1576
+ sync_one(conn, file_path, project_root=project_root)
1577
+ new_sl = swim_eff
1578
+
1579
+ return ok(
1580
+ {
1581
+ "task_id": task_id,
1582
+ "previous_status": prev_status,
1583
+ "new_status": new_status,
1584
+ "previous_swimlane": cur_sl if wants_swim else None,
1585
+ "new_swimlane": new_sl if wants_swim else None,
1586
+ "warnings": warnings,
1587
+ },
1588
+ meta={"layer": "tasks", "source": "board_os.cos_task_reposition"},
1589
+ )
1590
+
1591
+
1592
+ # ---------- cos_task_ready ----------
1593
+
1594
+
1595
+ def _labels_list_from_json(raw: object) -> list[str]:
1596
+ if not raw:
1597
+ return []
1598
+ if isinstance(raw, (list, tuple)):
1599
+ return [str(x) for x in raw]
1600
+ if isinstance(raw, str) and raw.strip():
1601
+ try:
1602
+ parsed = json.loads(raw)
1603
+ except json.JSONDecodeError:
1604
+ return [t.strip() for t in raw.split(",") if t.strip()]
1605
+ if isinstance(parsed, list):
1606
+ return [str(x) for x in parsed]
1607
+ return []
1608
+
1609
+
1610
+ def _patch_labels_line(file_path: Path, labels: list[str]) -> None:
1611
+ content = file_path.read_text(encoding="utf-8")
1612
+ flow = "[" + ", ".join(labels) + "]"
1613
+ fm_re = re.compile(r"^(---\s*\n.*?\n---\s*\n)", re.DOTALL)
1614
+ m = fm_re.match(content)
1615
+ if not m:
1616
+ raise ValueError(f"{file_path}: no frontmatter to patch")
1617
+ head = m.group(1)
1618
+ label_re = re.compile(r"^labels:.*$", re.MULTILINE)
1619
+ if label_re.search(head):
1620
+ new_head = label_re.sub(f"labels: {flow}", head, count=1)
1621
+ else:
1622
+ new_head = head.replace("---\n", f"---\nlabels: {flow}\n", 1)
1623
+ new_content = new_head + content[m.end() :]
1624
+ tmp = file_path.with_suffix(file_path.suffix + ".tmp")
1625
+ tmp.write_text(new_content, encoding="utf-8")
1626
+ os.replace(tmp, file_path)
1627
+
1628
+
1629
+ def _ready_dor_check(
1630
+ file_path: Path,
1631
+ agent_session: str | None,
1632
+ ) -> tuple[list[dict[str, str]], str | None]:
1633
+ from board_os.transition_gates import GatesConfigError, load_gates_config
1634
+ from board_os.transition_gates_validator import evaluate_dor, evaluate_override
1635
+ from board_os.workflow import _extract_kind_from_frontmatter
1636
+
1637
+ try:
1638
+ body = file_path.read_text(encoding="utf-8")
1639
+ kind = _extract_kind_from_frontmatter(body) or "feature"
1640
+ config = load_gates_config()
1641
+ result = evaluate_dor(kind, body, config)
1642
+ except (GatesConfigError, OSError, ValueError) as exc:
1643
+ return [{"code": "DOR_CHECK_SKIPPED", "severity": "warn", "message": str(exc)}], None
1644
+
1645
+ gaps = [
1646
+ {"code": m.code, "severity": m.severity.value, "message": m.message}
1647
+ for m in result.messages
1648
+ ]
1649
+ # Warn-default: surface gaps but still let the label land. Block only when
1650
+ # the operator opted into COS_READY_DOR=strict AND the DoR actually fails.
1651
+ if not result.blocked or os.environ.get("COS_READY_DOR") != "strict":
1652
+ return gaps, None
1653
+
1654
+ if os.environ.get("COS_DOR_OVERRIDE") == "1":
1655
+ override_result, _request = evaluate_override(
1656
+ "dor",
1657
+ reason=os.environ.get("COS_OVERRIDE_REASON"),
1658
+ actor=os.environ.get("COS_AGENT") or agent_session,
1659
+ config=config,
1660
+ )
1661
+ if not override_result.blocked:
1662
+ return gaps, None # override accepted — proceed, gaps stay advisory
1663
+ rejected = "; ".join(m.message for m in override_result.messages)
1664
+ summary = "; ".join(f"[{g['code']}] {g['message']}" for g in gaps)
1665
+ return gaps, f"DoR not met and override rejected: {summary} | {rejected}"
1666
+
1667
+ summary = "; ".join(f"[{g['code']}] {g['message']}" for g in gaps)
1668
+ return gaps, (
1669
+ f"ready refused — Definition of Ready not met: {summary}. "
1670
+ "Fix the task body, unset COS_READY_DOR, or set "
1671
+ "COS_DOR_OVERRIDE=1 with a COS_OVERRIDE_REASON."
1672
+ )
1673
+
1674
+
1675
+ @safe_tool
1676
+ def cos_task_ready(
1677
+ conn: sqlite3.Connection,
1678
+ *,
1679
+ task_id: str,
1680
+ ready: bool = True,
1681
+ agent_session: str | None = None,
1682
+ ) -> str:
1683
+ """Add or remove the 'ready' label that gates icebox→in_progress."""
1684
+ row = conn.execute(
1685
+ "SELECT status, file_path, labels_json FROM tasks WHERE task_id = ?",
1686
+ (task_id,),
1687
+ ).fetchone()
1688
+ if row is None:
1689
+ return fail("not_found", f"task {task_id} not found")
1690
+
1691
+ labels = _labels_list_from_json(row[2])
1692
+ has_ready = READY_LABEL in labels
1693
+ if ready == has_ready:
1694
+ return ok(
1695
+ {
1696
+ "task_id": task_id,
1697
+ "ready": ready,
1698
+ "labels": labels,
1699
+ "warnings": [
1700
+ f"no-op (label '{READY_LABEL}' already {'set' if ready else 'absent'})"
1701
+ ],
1702
+ },
1703
+ meta={"layer": "tasks", "source": "board_os.cos_task_ready"},
1704
+ )
1705
+
1706
+ project_root = _project_root()
1707
+ rel_path = row[1]
1708
+ file_path = project_root / rel_path if rel_path else None
1709
+
1710
+ # DoR surfacing (TASK-258): reuse the icebox→in_progress validator so a
1711
+ # task can't be silently labeled ready while incomplete. Runs BEFORE the
1712
+ # label mutation so a strict-mode refusal leaves no half-applied change.
1713
+ dor_gaps: list[dict[str, str]] = []
1714
+ if ready and file_path is not None and file_path.exists():
1715
+ dor_gaps, block_reason = _ready_dor_check(file_path, agent_session)
1716
+ if block_reason is not None:
1717
+ return fail("validation", block_reason)
1718
+
1719
+ if ready:
1720
+ labels.append(READY_LABEL)
1721
+ else:
1722
+ labels = [lbl for lbl in labels if lbl != READY_LABEL]
1723
+
1724
+ if file_path is not None and file_path.exists():
1725
+ try:
1726
+ _patch_labels_line(file_path, labels)
1727
+ except (OSError, ValueError) as exc:
1728
+ return fail("validation", f"labels patch failed: {exc}")
1729
+ sync_one(conn, file_path, project_root=project_root)
1730
+ else:
1731
+ conn.execute(
1732
+ "UPDATE tasks SET labels_json = ? WHERE task_id = ?",
1733
+ (json.dumps(labels), task_id),
1734
+ )
1735
+ conn.commit()
1736
+
1737
+ data: dict[str, object] = {
1738
+ "task_id": task_id,
1739
+ "ready": ready,
1740
+ "labels": labels,
1741
+ "status": str(row[0]),
1742
+ }
1743
+ if dor_gaps:
1744
+ data["dor"] = dor_gaps
1745
+ return ok(data, meta={"layer": "tasks", "source": "board_os.cos_task_ready"})
1746
+
1747
+
1748
+ # ---------- cos_task_reclaim (zombie in_progress recovery) ----------
1749
+
1750
+
1751
+ def _active_session_ids(now: float, window: int = 1800) -> set[str]:
1752
+ # Reads agent-presence JSON under $COS_STATE_DIR/<agent>/sessions/*.json
1753
+ # (written by agent-presence.sh). Missing/unreadable presence → "no active
1754
+ # sessions", so reclaim falls back to the idle-only signal.
1755
+ ids: set[str] = set()
1756
+ state_dir = os.environ.get("COS_STATE_DIR") or str(_project_root() / ".coding-os")
1757
+ base = Path(state_dir)
1758
+ if not base.is_dir():
1759
+ return ids
1760
+ for sess_dir in base.glob("*/sessions"):
1761
+ for jf in sess_dir.glob("*.json"):
1762
+ try:
1763
+ d = json.loads(jf.read_text(encoding="utf-8"))
1764
+ except (OSError, json.JSONDecodeError):
1765
+ continue
1766
+ if d.get("ended_at"):
1767
+ continue
1768
+ last = 0
1769
+ for key in ("last_tool_at", "last_prompt_at", "started_at"):
1770
+ val = d.get(key)
1771
+ if isinstance(val, (int, float)):
1772
+ last = max(last, int(val))
1773
+ if last and (now - last) < window:
1774
+ sid = d.get("session_id")
1775
+ if sid:
1776
+ ids.add(str(sid))
1777
+ return ids
1778
+
1779
+
1780
+ def _commits_referencing(task_id: str, project_root: Path) -> int | None:
1781
+ # None = unverifiable (no git / error) so callers fail SAFE — treat as "has
1782
+ # evidence", never auto-reclaim on a signal we couldn't check. Trailing
1783
+ # non-digit boundary stops TASK-215 also matching TASK-2155.
1784
+ import subprocess
1785
+
1786
+ try:
1787
+ out = subprocess.run(
1788
+ [
1789
+ "git",
1790
+ "-C",
1791
+ str(project_root),
1792
+ "log",
1793
+ "--all",
1794
+ "-E",
1795
+ f"--max-count={_COMMIT_SCAN_CAP}",
1796
+ "--grep",
1797
+ f"{task_id}([^0-9]|$)",
1798
+ "--oneline",
1799
+ ],
1800
+ capture_output=True,
1801
+ text=True,
1802
+ timeout=5,
1803
+ )
1804
+ except (OSError, subprocess.SubprocessError):
1805
+ return None
1806
+ if out.returncode != 0:
1807
+ return None
1808
+ return sum(1 for line in out.stdout.splitlines() if line.strip())
1809
+
1810
+
1811
+ # Cap on how many matching commits git enumerates per scan — bounds the history
1812
+ # walk at 1M+ commits. Reconciliation only needs "0 vs >0" evidence, so a count
1813
+ # capped at this value is sufficient (and reported as "at least N"). TASK-227.
1814
+ _COMMIT_SCAN_CAP = 500
1815
+ # Cap on a single reclaim/reconcile sweep — the rest drains on the next run.
1816
+ _STRANDED_SCAN_LIMIT = 1000
1817
+
1818
+
1819
+ def _commits_referencing_batch(task_ids: list[str], project_root: Path) -> dict[str, int | None]:
1820
+ # One history walk for many ids — replaces N per-task subprocesses. All-None
1821
+ # when git is unavailable so callers fail SAFE (unverifiable = has evidence).
1822
+ import re
1823
+ import subprocess
1824
+
1825
+ ids = [t for t in dict.fromkeys(task_ids) if t]
1826
+ if not ids:
1827
+ return {}
1828
+ counts: dict[str, int | None] = {tid: 0 for tid in ids}
1829
+ grep_args: list[str] = []
1830
+ for tid in ids:
1831
+ grep_args += ["--grep", f"{tid}([^0-9]|$)"]
1832
+ try:
1833
+ out = subprocess.run(
1834
+ [
1835
+ "git",
1836
+ "-C",
1837
+ str(project_root),
1838
+ "log",
1839
+ "--all",
1840
+ "-E",
1841
+ f"--max-count={_COMMIT_SCAN_CAP}",
1842
+ *grep_args,
1843
+ "--format=%s",
1844
+ ],
1845
+ capture_output=True,
1846
+ text=True,
1847
+ timeout=15,
1848
+ )
1849
+ except (OSError, subprocess.SubprocessError):
1850
+ return {tid: None for tid in ids}
1851
+ if out.returncode != 0:
1852
+ return {tid: None for tid in ids}
1853
+ patterns = {tid: re.compile(re.escape(tid) + r"([^0-9]|$)") for tid in ids}
1854
+ for line in out.stdout.splitlines():
1855
+ for tid, pat in patterns.items():
1856
+ if pat.search(line):
1857
+ counts[tid] += 1
1858
+ return counts
1859
+
1860
+
1861
+ def _has_work_log(work_log_json: object) -> bool:
1862
+ try:
1863
+ return bool(json.loads(work_log_json or "[]"))
1864
+ except (json.JSONDecodeError, TypeError):
1865
+ return False
1866
+
1867
+
1868
+ def _classify_stranded(status: str, commits: int | None, has_work_log: bool) -> str:
1869
+ # commits is None = unverifiable (no git / error) — counted AS evidence so a
1870
+ # task is never called abandoned on a signal we couldn't check.
1871
+ has_commit_evidence = commits is None or commits > 0
1872
+ if status == "testing" and (has_commit_evidence or has_work_log):
1873
+ return "likely_complete"
1874
+ if status == "in_progress" and commits == 0 and not has_work_log:
1875
+ return "likely_abandoned"
1876
+ return "needs_review"
1877
+
1878
+
1879
+ def _reconcile_recommendation(task_id: str, classification: str, commits: int) -> str:
1880
+ n = "?" if commits is None else commits
1881
+ if classification == "zombie_icebox":
1882
+ return (
1883
+ f"Work log claims finished work but the card never left icebox. "
1884
+ f"Verify the change is live, then `cos task-start {task_id}` -> testing -> "
1885
+ f"`cos task-done {task_id}`; if the claim is wrong, resume or park deliberately."
1886
+ )
1887
+ if classification == "likely_complete":
1888
+ return (
1889
+ f"Looks finished ({n} commit(s) reference it, reached testing). "
1890
+ f"Review acceptance, then `cos task-done {task_id}`; if not actually "
1891
+ f"done, `cos task-start {task_id}` to resume."
1892
+ )
1893
+ if classification == "likely_abandoned":
1894
+ return (
1895
+ f"No committed progress — `cos task-cancel {task_id} --park` to shelve, "
1896
+ f"or `cos task-start {task_id}` to resume."
1897
+ )
1898
+ return f"Review with `cos task-show {task_id}` -> complete, resume, or park."
1899
+
1900
+
1901
+ @safe_tool
1902
+ def cos_task_reclaim(
1903
+ conn: sqlite3.Connection,
1904
+ *,
1905
+ idle_hours: int | None = None,
1906
+ dry_run: bool = False,
1907
+ agent_session: str | None = None,
1908
+ ) -> str:
1909
+ """Reclaim zombie in_progress/testing/emergency tasks (idle + owner inactive); testing->in_progress, else->icebox."""
1910
+ config = _current_config()
1911
+ default_threshold_h = (
1912
+ idle_hours
1913
+ if idle_hours is not None
1914
+ else (config.workflow_policy.reclaim_idle_hours if config is not None else 24)
1915
+ )
1916
+
1917
+ def _threshold_for(status: str) -> int:
1918
+ # Per-status idle window. `testing` is mid-flight work funneled there by
1919
+ # the testing-first protocol, so reclaim it sooner than a generic
1920
+ # in_progress zombie. An explicit idle_hours arg overrides all statuses.
1921
+ if idle_hours is not None or config is None:
1922
+ return default_threshold_h
1923
+ if status == "testing":
1924
+ t = config.workflow_policy.testing_reclaim_idle_hours
1925
+ return t if t > 0 else config.workflow_policy.reclaim_idle_hours
1926
+ return config.workflow_policy.reclaim_idle_hours
1927
+
1928
+ now = time.time()
1929
+ active = _active_session_ids(now)
1930
+ project_root = _project_root()
1931
+
1932
+ # Widened from in_progress-only (RC3): a `testing` zombie was previously
1933
+ # un-reclaimable by every path, which is exactly where the protocol parks
1934
+ # near-done work at the moment of session death.
1935
+ rows = conn.execute(
1936
+ "SELECT task_id, agent_session, started_at, file_path, status, work_log_last_5 "
1937
+ "FROM tasks WHERE status IN ('in_progress', 'testing', 'emergency') "
1938
+ "ORDER BY started_at LIMIT ?",
1939
+ (_STRANDED_SCAN_LIMIT,),
1940
+ ).fetchall()
1941
+ # Batch the per-testing-task git lookup into ONE history walk (was N
1942
+ # subprocesses, each O(history) at 1M commits). TASK-227.
1943
+ commits_by_task = _commits_referencing_batch(
1944
+ [r[0] for r in rows if r[4] == "testing"], project_root
1945
+ )
1946
+
1947
+ reclaimed: list[dict] = []
1948
+ skipped_for_review: list[dict] = []
1949
+ for task_id, owner, started_at, rel, status, work_log in rows:
1950
+ hist = conn.execute(
1951
+ "SELECT MAX(transitioned_at) FROM task_status_history WHERE task_id = ?",
1952
+ (task_id,),
1953
+ ).fetchone()
1954
+ last_activity = max(
1955
+ int(started_at or 0),
1956
+ int(hist[0]) if hist and hist[0] else 0,
1957
+ )
1958
+ # No activity signal at all → too risky to reclaim; skip.
1959
+ if last_activity == 0:
1960
+ continue
1961
+ threshold_h = _threshold_for(status)
1962
+ idle_s = now - last_activity
1963
+ if idle_s < threshold_h * 3600:
1964
+ continue
1965
+ # Owner still actively present → never reclaim its work.
1966
+ if owner and owner in active:
1967
+ continue
1968
+
1969
+ # Don't blindly recycle a probably-FINISHED task. A testing
1970
+ # zombie with committed/logged work is almost certainly done — the agent
1971
+ # just forgot task-done. Leave it in testing for review (cos_task_reconcile
1972
+ # surfaces it) instead of recycling it to in_progress.
1973
+ if status == "testing":
1974
+ commits = commits_by_task.get(task_id)
1975
+ # None = unverifiable (no git) → counts as evidence so we never
1976
+ # recycle a testing card on a signal we could not check.
1977
+ if _has_work_log(work_log) or commits is None or commits > 0:
1978
+ skipped_for_review.append({"task_id": task_id, "previous_owner": owner})
1979
+ continue
1980
+
1981
+ # Status-aware destination: a testing zombie is near-done, so return it
1982
+ # to in_progress (a legal unforced edge) to resume the work rather than
1983
+ # dumping it to the backlog; in_progress/emergency zombies go to icebox.
1984
+ dest = "in_progress" if status == "testing" else "icebox"
1985
+ idle_h = round(idle_s / 3600, 1)
1986
+ if dry_run:
1987
+ reclaimed.append(
1988
+ {
1989
+ "task_id": task_id,
1990
+ "previous_owner": owner,
1991
+ "idle_hours": idle_h,
1992
+ "from_status": status,
1993
+ "to_status": dest,
1994
+ }
1995
+ )
1996
+ continue
1997
+
1998
+ file_path = project_root / rel if rel else None
1999
+ # Only a backlog-bound (icebox) reclaim needs the ready label so the
2000
+ # card stays pullable; a testing->in_progress reclaim keeps its labels.
2001
+ if dest == "icebox" and file_path is not None and file_path.exists():
2002
+ cur_labels = _labels_list_from_json(
2003
+ conn.execute(
2004
+ "SELECT labels_json FROM tasks WHERE task_id = ?", (task_id,)
2005
+ ).fetchone()[0]
2006
+ )
2007
+ if READY_LABEL not in cur_labels:
2008
+ cur_labels.append(READY_LABEL)
2009
+ try:
2010
+ _patch_labels_line(file_path, cur_labels)
2011
+ sync_one(conn, file_path, project_root=project_root)
2012
+ except (OSError, ValueError) as exc:
2013
+ logger.debug("reclaim label patch failed for %s: %s", task_id, exc)
2014
+
2015
+ result = transition(
2016
+ conn,
2017
+ task_id,
2018
+ dest,
2019
+ reason=f"reclaim: {status} idle {idle_h}h, owner session inactive -> {dest}",
2020
+ # Unattended runs (nightly daemon) pass no session; attribute the
2021
+ # healing to the system actor, not the human fallback.
2022
+ agent_session=agent_session or f"{SYSTEM_SESSION_PREFIX}-reclaim",
2023
+ force=True,
2024
+ config=config,
2025
+ file_path=file_path,
2026
+ )
2027
+ if result.ok:
2028
+ reclaimed.append(
2029
+ {
2030
+ "task_id": task_id,
2031
+ "previous_owner": owner,
2032
+ "idle_hours": idle_h,
2033
+ "from_status": status,
2034
+ "to_status": dest,
2035
+ }
2036
+ )
2037
+
2038
+ return ok(
2039
+ {
2040
+ "reclaimed": reclaimed,
2041
+ "count": len(reclaimed),
2042
+ "skipped_for_review": skipped_for_review,
2043
+ "dry_run": dry_run,
2044
+ "idle_hours_threshold": default_threshold_h,
2045
+ "active_sessions": len(active),
2046
+ },
2047
+ meta={"layer": "tasks", "source": "board_os.cos_task_reclaim"},
2048
+ )
2049
+
2050
+
2051
+ @safe_tool
2052
+ def cos_task_reconcile(conn: sqlite3.Connection, *, include_active: bool = False) -> str:
2053
+ """Triage stranded in_progress/testing tasks and icebox zombies (completion evidence, no lifecycle) — read-only."""
2054
+ now = time.time()
2055
+ active = _active_session_ids(now)
2056
+ project_root = _project_root()
2057
+ rows = conn.execute(
2058
+ "SELECT task_id, agent_session, status, started_at, work_log_last_5, "
2059
+ " (SELECT MAX(transitioned_at) FROM task_status_history h "
2060
+ " WHERE h.task_id = tasks.task_id) "
2061
+ "FROM tasks WHERE status IN ('in_progress', 'testing', 'emergency') "
2062
+ "ORDER BY status DESC, task_id LIMIT ?",
2063
+ (_STRANDED_SCAN_LIMIT,),
2064
+ ).fetchall()
2065
+ # Pre-filter to the rows we'll actually triage (default = stranded only),
2066
+ # then batch the git lookup into ONE history walk instead of one subprocess
2067
+ # per row. TASK-227.
2068
+ triaged = [r for r in rows if include_active or not (r[1] and r[1] in active)]
2069
+ # Zombies: icebox cards whose work log already claims finished work. The
2070
+ # commit-subject count is NOT the signal here — the card-filing commit
2071
+ # mentions every task id, so only the work-log claim distinguishes a zombie.
2072
+ zombie_rows = conn.execute(
2073
+ "SELECT task_id, agent_session, status, started_at, work_log_last_5, "
2074
+ " (SELECT MAX(transitioned_at) FROM task_status_history h "
2075
+ " WHERE h.task_id = tasks.task_id) "
2076
+ "FROM tasks WHERE status = 'icebox' AND work_log_last_5 IS NOT NULL "
2077
+ "ORDER BY task_id LIMIT ?",
2078
+ (_STRANDED_SCAN_LIMIT,),
2079
+ ).fetchall()
2080
+ zombies = [r for r in zombie_rows if _completion_evidence(r[4])]
2081
+ commits_by_task = _commits_referencing_batch(
2082
+ [r[0] for r in triaged] + [r[0] for r in zombies], project_root
2083
+ )
2084
+ items: list[dict] = []
2085
+ for task_id, owner, status, started_at, work_log, last_tx in triaged + zombies:
2086
+ owner_active = bool(owner and owner in active)
2087
+ commits = commits_by_task.get(task_id)
2088
+ has_wl = _has_work_log(work_log)
2089
+ if status == "icebox":
2090
+ classification = "zombie_icebox"
2091
+ else:
2092
+ classification = _classify_stranded(status, commits, has_wl)
2093
+ dwell = _status_dwell_seconds(now, started_at, last_tx)
2094
+ items.append(
2095
+ {
2096
+ "task_id": task_id,
2097
+ "status": status,
2098
+ "previous_owner": owner,
2099
+ "owner_active": owner_active,
2100
+ "commits_referencing": commits,
2101
+ "has_work_log": has_wl,
2102
+ "status_dwell_seconds": dwell,
2103
+ "status_dwell_human": _humanize_duration(dwell),
2104
+ "classification": classification,
2105
+ "recommendation": _reconcile_recommendation(task_id, classification, commits),
2106
+ }
2107
+ )
2108
+ summary = {
2109
+ "likely_complete": sum(1 for i in items if i["classification"] == "likely_complete"),
2110
+ "likely_abandoned": sum(1 for i in items if i["classification"] == "likely_abandoned"),
2111
+ "needs_review": sum(1 for i in items if i["classification"] == "needs_review"),
2112
+ "zombie_icebox": sum(1 for i in items if i["classification"] == "zombie_icebox"),
2113
+ }
2114
+ return ok(
2115
+ {"stranded": items, "count": len(items), "summary": summary},
2116
+ meta={"layer": "tasks", "source": "board_os.cos_task_reconcile"},
2117
+ )
2118
+
2119
+
2120
+ _KEEP_LABELS = ("keep", "parked")
2121
+
2122
+
2123
+ def _archive_stale_sweep(conn: sqlite3.Connection, config) -> list[dict]:
2124
+ # OFF by default: runs only when a status's *_auto_archive_days knob is > 0,
2125
+ # so a fresh project never silently deletes backlog. keep/parked labels exempt
2126
+ # a card; archive is reversible (archive->icebox is legal). Fail-soft per card.
2127
+ if config is None:
2128
+ return []
2129
+ policy = config.workflow_policy
2130
+ plans: list[tuple[str, int]] = []
2131
+ if getattr(policy, "icebox_auto_archive_days", 0) > 0:
2132
+ plans.append(("icebox", policy.icebox_auto_archive_days * 86400))
2133
+ if getattr(policy, "complete_auto_archive_days", 0) > 0:
2134
+ plans.append(("complete", policy.complete_auto_archive_days * 86400))
2135
+ if not plans:
2136
+ return []
2137
+
2138
+ now = time.time()
2139
+ project_root = _project_root()
2140
+ archived: list[dict] = []
2141
+ for status, threshold_s in plans:
2142
+ rows = conn.execute(
2143
+ "SELECT task_id, started_at, file_path, labels_json, "
2144
+ " (SELECT MAX(transitioned_at) FROM task_status_history h "
2145
+ " WHERE h.task_id = tasks.task_id) "
2146
+ "FROM tasks WHERE status = ? "
2147
+ "ORDER BY started_at ASC LIMIT ?", # oldest first; rest drains next run
2148
+ (status, _STRANDED_SCAN_LIMIT),
2149
+ ).fetchall()
2150
+ for task_id, started_at, rel, labels_json, last_tx in rows:
2151
+ dwell = _status_dwell_seconds(now, started_at, last_tx)
2152
+ if dwell is None or dwell < threshold_s:
2153
+ continue
2154
+ if any(lbl in _KEEP_LABELS for lbl in _labels_list_from_json(labels_json)):
2155
+ continue
2156
+ file_path = project_root / rel if rel else None
2157
+ result = transition(
2158
+ conn,
2159
+ task_id,
2160
+ "archive",
2161
+ reason=f"auto-archive: {status} idle {round(dwell / 86400, 1)}d",
2162
+ # System attribution, never None — a NULL session renders as the
2163
+ # human operator in the stream panel (hub-architecture.md
2164
+ # § Actor attribution contract).
2165
+ agent_session=f"{SYSTEM_SESSION_PREFIX}-auto-archive",
2166
+ force=True,
2167
+ config=config,
2168
+ file_path=file_path,
2169
+ )
2170
+ if result.ok:
2171
+ archived.append(
2172
+ {"task_id": task_id, "from_status": status, "age_days": round(dwell / 86400, 1)}
2173
+ )
2174
+ else:
2175
+ # Surface per-task failures instead of silently dropping them so
2176
+ # the daily "N archived" count can't hide stranded cards.
2177
+ logger.warning("auto-archive transition failed for %s (%s)", task_id, status)
2178
+ return archived
2179
+
2180
+
2181
+ # ---------- cos_task_pick ----------
2182
+
2183
+
2184
+ _PRIORITY_WEIGHT = {"P0": 100, "P1": 50, "P2": 20, "P3": 5}
2185
+
2186
+
2187
+ @safe_tool
2188
+ def cos_task_pick(
2189
+ conn: sqlite3.Connection,
2190
+ *,
2191
+ swimlane: str | None = None,
2192
+ priority_min: str = "P2",
2193
+ max_candidates: int = 5,
2194
+ ) -> str:
2195
+ pm_weight = _PRIORITY_WEIGHT.get(priority_min, 20)
2196
+ # "ready" is no longer a column — candidates now live in icebox with
2197
+ # a 'ready' label, plus the emergency column. LIKE on labels_json
2198
+ # is cheap (<200 chars) and avoids a JSON1 dependency.
2199
+ #
2200
+ # Dependency filter: a ready icebox card with any prerequisite that is not
2201
+ # `complete` isn't runnable now, so it's excluded via NOT EXISTS over the
2202
+ # indexed task_dependencies junction (a missing dep row — never synced —
2203
+ # has no status and counts as incomplete). emergency cards are unaffected.
2204
+ # Guarded on the junction existing so a pre-v35 DB still returns candidates.
2205
+ if _has_task_dependencies_table(conn):
2206
+ ready_clause = (
2207
+ "(status = 'icebox' AND labels_json LIKE '%\"ready\"%' "
2208
+ "AND NOT EXISTS ("
2209
+ " SELECT 1 FROM task_dependencies d "
2210
+ " LEFT JOIN tasks dep ON dep.task_id = d.depends_on "
2211
+ " WHERE d.task_id = tasks.task_id "
2212
+ " AND (dep.status IS NULL OR dep.status != 'complete')))"
2213
+ )
2214
+ else:
2215
+ ready_clause = "(status = 'icebox' AND labels_json LIKE '%\"ready\"%')"
2216
+ clauses = [f"(status = 'emergency' OR {ready_clause})"]
2217
+ params: list = []
2218
+ if swimlane:
2219
+ clauses.append("swimlane = ?")
2220
+ params.append(swimlane)
2221
+ # Bounded: highest-priority candidates first, capped — pick only needs the
2222
+ # top max_candidates, and the cap keeps a 10K-ready icebox from a full load.
2223
+ query = f"{_BOARD_SELECT} WHERE {' AND '.join(clauses)} ORDER BY priority LIMIT 1000"
2224
+ rows = conn.execute(query, params).fetchall()
2225
+
2226
+ scored: list[tuple[int, dict]] = []
2227
+ for row in rows:
2228
+ card = _task_card(row)
2229
+ p = _PRIORITY_WEIGHT.get(card["priority"], 0)
2230
+ if p < pm_weight:
2231
+ continue
2232
+ score = p + (30 if card["status"] == "emergency" else 0)
2233
+ scored.append((score, card))
2234
+
2235
+ scored.sort(key=lambda x: -x[0])
2236
+ top = [c for _, c in scored[:max_candidates]]
2237
+ return ok(
2238
+ {"candidates": top, "count": len(top)},
2239
+ meta={"layer": "tasks", "source": "board_os.cos_task_pick"},
2240
+ )
2241
+
2242
+
2243
+ # ---------- cos_task_claim_next ----------
2244
+
2245
+
2246
+ @safe_tool
2247
+ def cos_task_claim_next(
2248
+ conn: sqlite3.Connection,
2249
+ *,
2250
+ swimlane: str | None = None,
2251
+ priority_min: str = "P2",
2252
+ agent_session: str | None = None,
2253
+ ) -> str:
2254
+ """Atomically claim the highest-priority runnable task for this session.
2255
+
2256
+ Select + claim in ONE step so N racing sessions each get a DISTINCT task or
2257
+ ``{claimed: null}`` — never the same task twice, never an exception. Reuses
2258
+ cos_task_pick (dependency-filtered, priority-ordered) for candidates, then
2259
+ walks them attempting an atomic ``→ in_progress`` move: transition's
2260
+ BEGIN IMMEDIATE + CAS ``WHERE status = <expected>`` lets exactly one session
2261
+ win each row; a loser's CAS-miss (category `transient`) is skipped to the
2262
+ next candidate. A per-session WIP-cap rejection stops the walk — this session
2263
+ is already at its focus limit — and returns ``{claimed: null}``.
2264
+ """
2265
+ agent_session = _resolve_attribution(agent_session)
2266
+ config = _current_config()
2267
+
2268
+ # A wider window than max_candidates: under contention the top few rows may
2269
+ # all be claimed by peers before this session wins one, so scan deeper.
2270
+ pick_env = json.loads(
2271
+ cos_task_pick(conn, swimlane=swimlane, priority_min=priority_min, max_candidates=50)
2272
+ )
2273
+ if not pick_env.get("ok"):
2274
+ return fail("internal", "claim-next could not enumerate candidates")
2275
+ candidates = pick_env["data"]["candidates"]
2276
+
2277
+ for card in candidates:
2278
+ expected_from = card["status"] # 'icebox' (ready) or 'emergency'
2279
+ result = transition(
2280
+ conn,
2281
+ card["id"],
2282
+ "in_progress",
2283
+ reason="claim-next",
2284
+ agent_session=agent_session,
2285
+ expected_from=expected_from,
2286
+ config=config,
2287
+ file_path=_resolve_task_file(conn, card["id"]),
2288
+ )
2289
+ if result.ok:
2290
+ claimed = json.loads(cos_task_show(conn, task_id=card["id"]))
2291
+ return ok(
2292
+ {"claimed": claimed.get("data") if claimed.get("ok") else {"id": card["id"]}},
2293
+ meta={"layer": "tasks", "source": "board_os.cos_task_claim_next"},
2294
+ )
2295
+ # A peer beat us to this row (CAS miss / status changed) — try the next.
2296
+ if result.error_category == "transient":
2297
+ continue
2298
+ # WIP cap or a hard gate: this session can't take on more work now.
2299
+ break
2300
+
2301
+ return ok(
2302
+ {"claimed": None},
2303
+ meta={"layer": "tasks", "source": "board_os.cos_task_claim_next"},
2304
+ )
2305
+
2306
+
2307
+ def _resolve_task_file(conn: sqlite3.Connection, task_id: str) -> Path | None:
2308
+ row = conn.execute("SELECT file_path FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
2309
+ if not row or not row[0]:
2310
+ return None
2311
+ candidate = _project_root() / row[0]
2312
+ return candidate if candidate.exists() else None
2313
+
2314
+
2315
+ # ---------- cos_task_daily ----------
2316
+
2317
+
2318
+ @safe_tool
2319
+ def cos_task_daily(
2320
+ conn: sqlite3.Connection,
2321
+ *,
2322
+ since: str = "24h",
2323
+ agent_session: str | None = None,
2324
+ ) -> str:
2325
+ hours = _parse_since(since)
2326
+ threshold = int(time.time() - hours * 3600)
2327
+
2328
+ # Self-heal at the session-start ritual: reclaim zombie in_progress
2329
+ # tasks (idle + owner session inactive) before reporting state.
2330
+ # Fire-and-forget — daily must never fail on the reclaim path.
2331
+ config = _current_config()
2332
+
2333
+ reclaimed: list[dict] = []
2334
+ try:
2335
+ rec_env = json.loads(cos_task_reclaim(conn, agent_session=agent_session))
2336
+ if rec_env.get("ok"):
2337
+ reclaimed = rec_env["data"]["reclaimed"]
2338
+ except Exception as exc: # noqa: BLE001 - fire-and-forget
2339
+ logger.debug("daily reclaim skipped: %s", exc)
2340
+
2341
+ # Icebox outflow — auto-archive aged backlog/complete cards when the project
2342
+ # opted in (default off). Runs before the status queries so archived cards
2343
+ # drop out of the report naturally. Fire-and-forget.
2344
+ auto_archived: list[dict] = []
2345
+ try:
2346
+ auto_archived = _archive_stale_sweep(conn, config)
2347
+ except Exception as exc: # noqa: BLE001 - fire-and-forget
2348
+ logger.debug("daily archive sweep skipped: %s", exc)
2349
+
2350
+ # Bounded standup queries (TASK-227): a 24h window or a runaway icebox must
2351
+ # not fetchall unboundedly. Active columns are WIP-small; icebox uses an
2352
+ # accurate COUNT + a bounded oldest-first sample for the stale preview.
2353
+ # Standup highlights only — most-recent N transitions, not the full window
2354
+ # (an unbounded list both OOMs at scale and blows the 32KB agent envelope).
2355
+ recent = conn.execute(
2356
+ "SELECT task_id, old_status, new_status, reason, transitioned_at "
2357
+ "FROM task_status_history "
2358
+ "WHERE transitioned_at >= ? "
2359
+ "ORDER BY transitioned_at DESC LIMIT 50",
2360
+ (threshold,),
2361
+ ).fetchall()
2362
+
2363
+ in_progress = conn.execute(
2364
+ f"{_BOARD_SELECT} WHERE status = 'in_progress' ORDER BY priority LIMIT 200"
2365
+ ).fetchall()
2366
+ # `testing` was previously absent from daily — the protocol funnels work
2367
+ # there before completion, so an abandoned card most often rots in testing
2368
+ # (RC3). Report it so a stranded testing zombie is visible at standup.
2369
+ testing = conn.execute(
2370
+ f"{_BOARD_SELECT} WHERE status = 'testing' ORDER BY priority LIMIT 200"
2371
+ ).fetchall()
2372
+ blocked = conn.execute(
2373
+ f"{_BOARD_SELECT} WHERE status = 'blocked' ORDER BY priority LIMIT 200"
2374
+ ).fetchall()
2375
+ icebox_total = conn.execute("SELECT COUNT(*) FROM tasks WHERE status = 'icebox'").fetchone()[0]
2376
+ icebox = conn.execute(
2377
+ f"{_BOARD_SELECT} WHERE status = 'icebox' ORDER BY last_transition_at ASC LIMIT 500"
2378
+ ).fetchall()
2379
+
2380
+ wip = None
2381
+ if config is not None:
2382
+ state = check_wip(conn, config)
2383
+ wip = {"counts": state.counts, "caps": state.caps}
2384
+
2385
+ in_progress_cards = [_flag_stale(_task_card(r), config) for r in in_progress]
2386
+ testing_cards = [_flag_stale(_task_card(r), config) for r in testing]
2387
+ blocker_cards = [_flag_stale(_task_card(r), config) for r in blocked]
2388
+ icebox_cards = [_flag_stale(_task_card(r), config) for r in icebox]
2389
+ icebox_stale = [c for c in icebox_cards if c.get("stale")]
2390
+ icebox_summary = {
2391
+ "total": icebox_total, # accurate count; cards below are a bounded sample
2392
+ "stale": len(icebox_stale),
2393
+ "stale_ids": [c["id"] for c in icebox_stale[:20]],
2394
+ }
2395
+
2396
+ return ok(
2397
+ {
2398
+ "yesterday": [
2399
+ {
2400
+ "task_id": r[0],
2401
+ "old_status": r[1],
2402
+ "new_status": r[2],
2403
+ "reason": r[3],
2404
+ "transitioned_at": r[4],
2405
+ }
2406
+ for r in recent
2407
+ ],
2408
+ "in_progress": in_progress_cards,
2409
+ "testing": testing_cards,
2410
+ "blockers": blocker_cards,
2411
+ "icebox": icebox_summary,
2412
+ "wip": wip,
2413
+ "reclaimed": reclaimed,
2414
+ "auto_archived": auto_archived,
2415
+ },
2416
+ meta={"layer": "tasks", "source": "board_os.cos_task_daily"},
2417
+ )
2418
+
2419
+
2420
+ # ---------- cos_task_retro ----------
2421
+
2422
+
2423
+ @safe_tool
2424
+ def _hook_block_trend(conn: sqlite3.Connection, threshold: int, hours: float) -> dict | None:
2425
+ # Hook BLOCKs are mirrored into log_events (scope 'hook.<name>', kv
2426
+ # action=block) by cos_log_hook's durable sink — no new capture needed.
2427
+ # A falling blocks/session rate is the KPI that rules are being
2428
+ # internalized; both windows empty -> None keeps the retro noise-free.
2429
+ if not _has_table(conn, "log_events"):
2430
+ return None
2431
+
2432
+ def iso_utc(epoch: int) -> str:
2433
+ return datetime.utcfromtimestamp(epoch).strftime("%Y-%m-%dT%H:%M:%SZ")
2434
+
2435
+ def window(start: int, end: int) -> tuple[int, int, dict[str, int]]:
2436
+ rows = conn.execute(
2437
+ "SELECT scope, COALESCE(session_id, '') FROM log_events "
2438
+ "WHERE scope LIKE 'hook.%' AND kv LIKE ? "
2439
+ "AND created_at >= ? AND created_at < ?",
2440
+ ('%"action": "block"%', iso_utc(start), iso_utc(end)),
2441
+ ).fetchall()
2442
+ by_hook: dict[str, int] = {}
2443
+ sessions: set[str] = set()
2444
+ for scope, session in rows:
2445
+ hook = scope.removeprefix("hook.")
2446
+ by_hook[hook] = by_hook.get(hook, 0) + 1
2447
+ if session:
2448
+ sessions.add(session)
2449
+ return len(rows), len(sessions), by_hook
2450
+
2451
+ now = int(time.time())
2452
+ span = int(hours * 3600)
2453
+ blocks, session_count, by_hook = window(threshold, now)
2454
+ prev_blocks, prev_session_count, _ = window(threshold - span, threshold)
2455
+ if blocks == 0 and prev_blocks == 0:
2456
+ return None
2457
+ rate = round(blocks / max(1, session_count), 2)
2458
+ prev_rate = round(prev_blocks / max(1, prev_session_count), 2)
2459
+ if rate < prev_rate:
2460
+ trend = "improving"
2461
+ elif rate > prev_rate:
2462
+ trend = "worsening"
2463
+ else:
2464
+ trend = "flat"
2465
+ top = sorted(by_hook.items(), key=lambda item: -item[1])[:5]
2466
+ return {
2467
+ "blocks": blocks,
2468
+ "sessions": session_count,
2469
+ "blocks_per_session": rate,
2470
+ "previous_blocks_per_session": prev_rate,
2471
+ "trend": trend,
2472
+ "top_hooks": [{"hook": hook, "blocks": count} for hook, count in top],
2473
+ }
2474
+
2475
+
2476
+ def cos_task_retro(
2477
+ conn: sqlite3.Connection,
2478
+ *,
2479
+ since: str = "7d",
2480
+ page_size: int = 25,
2481
+ cursor: str = "",
2482
+ ) -> str:
2483
+ hours = _parse_since(since)
2484
+ threshold = int(time.time() - hours * 3600)
2485
+
2486
+ # Aggregates over the WHOLE window via a slim projection — serializing
2487
+ # every full card blew the 32k envelope budget at ~270 completions
2488
+ # (observed 178k, envelope_unshrinkable).
2489
+ window_rows = conn.execute(
2490
+ "SELECT swimlane, started_at, completed_at FROM tasks "
2491
+ "WHERE status = 'complete' AND completed_at >= ?",
2492
+ (threshold,),
2493
+ ).fetchall()
2494
+
2495
+ cycle_times_min = [
2496
+ (done - started) / 60.0 for _, started, done in window_rows if started and done
2497
+ ]
2498
+ avg_cycle = (sum(cycle_times_min) / len(cycle_times_min)) if cycle_times_min else None
2499
+
2500
+ per_lane: dict[str, int] = {}
2501
+ for lane, _, _ in window_rows:
2502
+ per_lane[lane or "(none)"] = per_lane.get(lane or "(none)", 0) + 1
2503
+
2504
+ emergency_count = conn.execute(
2505
+ "SELECT COUNT(*) FROM task_status_history "
2506
+ "WHERE new_status = 'emergency' AND transitioned_at >= ?",
2507
+ (threshold,),
2508
+ ).fetchone()[0]
2509
+
2510
+ # Highlights page — same keyset machinery as the board's complete column,
2511
+ # trimmed to digest fields (the long tail rides the cursor).
2512
+ cards, next_cursor, total = _keyset_column_page(
2513
+ conn,
2514
+ "complete",
2515
+ ["completed_at >= ?"],
2516
+ [threshold],
2517
+ cursor or None,
2518
+ page_size,
2519
+ _current_config(),
2520
+ )
2521
+ digest_fields = ("id", "title", "swimlane", "kind", "priority", "completed_at")
2522
+ completed = [{k: c.get(k) for k in digest_fields} for c in cards]
2523
+
2524
+ payload = {
2525
+ "completed": completed,
2526
+ "completed_count": total,
2527
+ "cycle_time_avg_minutes": avg_cycle,
2528
+ "emergency_count": emergency_count,
2529
+ "swimlane_throughput": per_lane,
2530
+ "next_cursor": next_cursor,
2531
+ }
2532
+ block_trend = _hook_block_trend(conn, threshold, hours)
2533
+ if block_trend is not None:
2534
+ payload["hook_block_trend"] = block_trend
2535
+ return ok(
2536
+ payload,
2537
+ meta={
2538
+ "layer": "tasks",
2539
+ "source": "board_os.cos_task_retro",
2540
+ "truncated": bool(next_cursor),
2541
+ },
2542
+ )
2543
+
2544
+
2545
+ # ---------- cos_task_wip_check ----------
2546
+
2547
+
2548
+ @safe_tool
2549
+ def cos_task_wip_check(conn: sqlite3.Connection) -> str:
2550
+ config = _current_config()
2551
+ if config is None:
2552
+ return fail(
2553
+ "unavailable",
2554
+ "scrumban-config.yaml not found — run `cos board-config --init`",
2555
+ )
2556
+ state = check_wip(conn, config)
2557
+ return ok(
2558
+ {
2559
+ "counts": state.counts,
2560
+ "caps": state.caps,
2561
+ "violations": list(state.violations),
2562
+ "over_cap": bool(state.violations),
2563
+ },
2564
+ meta={"layer": "tasks", "source": "board_os.cos_task_wip_check"},
2565
+ )
2566
+
2567
+
2568
+ # ---------- cos_work_log_append ----------
2569
+
2570
+
2571
+ _WORKLOG_SUMMARY_CAP = 120
2572
+
2573
+
2574
+ def _truncate_summary(text: str, cap: int = _WORKLOG_SUMMARY_CAP) -> str:
2575
+ # Trim at the last word boundary within the cap and mark the loss with a
2576
+ # single ellipsis, so a long note reads as deliberately shortened rather
2577
+ # than silently chopped mid-word. The ellipsis counts toward the cap, so
2578
+ # the returned string is always <= cap (the documented Work Log contract).
2579
+ flat = text.strip().replace("\n", " ")
2580
+ if len(flat) <= cap:
2581
+ return flat
2582
+ clipped = flat[: cap - 1].rstrip()
2583
+ boundary = clipped.rfind(" ")
2584
+ if boundary > 0:
2585
+ clipped = clipped[:boundary].rstrip()
2586
+ return f"{clipped}…"
2587
+
2588
+
2589
+ @safe_tool
2590
+ def cos_work_log_append(
2591
+ conn: sqlite3.Connection,
2592
+ *,
2593
+ task_id: str,
2594
+ summary: str | None = None,
2595
+ note: str | None = None,
2596
+ agent_session: str | None = None,
2597
+ source: str = "manual",
2598
+ ) -> str:
2599
+ """Append one line to a task's Work Log section in the MD file."""
2600
+ # G38: accept `note` as alias of `summary` — many task-driver
2601
+ # callers (and docs) pass `note=...`; the prior signature only
2602
+ # honoured `summary`, producing a 422 validation error.
2603
+ if summary is None and note is not None:
2604
+ summary = note
2605
+ if not isinstance(summary, str) or not summary.strip():
2606
+ return fail("validation", "summary (or note) is required")
2607
+ row = conn.execute(
2608
+ "SELECT file_path FROM tasks WHERE task_id = ?",
2609
+ (task_id,),
2610
+ ).fetchone()
2611
+ if row is None or not row[0]:
2612
+ return fail("not_found", f"task {task_id} has no file_path")
2613
+ file_path = _project_root() / row[0]
2614
+ if not file_path.exists():
2615
+ return fail("not_found", f"file missing: {file_path}")
2616
+
2617
+ date = datetime.utcnow().strftime("%Y-%m-%d")
2618
+ agent_label = _agent_label(agent_session)
2619
+ summary_trunc = _truncate_summary(summary)
2620
+ line = f"- {date} [{agent_label}]: {summary_trunc}"
2621
+
2622
+ content = file_path.read_text(encoding="utf-8")
2623
+ marker = "## Work Log"
2624
+ # Match the heading anchored at line start, not a `## Work Log` mention
2625
+ # inside prose (e.g. an Acceptance bullet) which a plain substring search
2626
+ # would hit first — landing the entry ABOVE the real section.
2627
+ head = _WORKLOG_HEADING_RE.search(content)
2628
+ if head is None:
2629
+ # Append a Work Log section at the end.
2630
+ new_content = content.rstrip() + f"\n\n{marker}\n{line}\n"
2631
+ else:
2632
+ # Insert at the end of the Work Log section (before the next H2
2633
+ # heading if any, else at EOF), both anchored at line start.
2634
+ nxt = re.search(r"(?m)^## ", content[head.end() :])
2635
+ insert_at = head.end() + nxt.start() if nxt else len(content)
2636
+ before = content[:insert_at].rstrip()
2637
+ after = content[insert_at:]
2638
+ new_content = f"{before}\n{line}\n{after}"
2639
+ file_path.write_text(new_content, encoding="utf-8")
2640
+
2641
+ # Re-sync to pick up the new log line.
2642
+ sync_one(conn, file_path, project_root=_project_root())
2643
+
2644
+ return ok(
2645
+ {
2646
+ "task_id": task_id,
2647
+ "line_appended": line,
2648
+ "source": source,
2649
+ },
2650
+ meta={"layer": "tasks", "source": "board_os.cos_work_log_append"},
2651
+ )
2652
+
2653
+
2654
+ # ---------- cos_task_history ----------
2655
+
2656
+
2657
+ def _has_table(conn: sqlite3.Connection, name: str) -> bool:
2658
+ return (
2659
+ conn.execute(
2660
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
2661
+ (name,),
2662
+ ).fetchone()
2663
+ is not None
2664
+ )
2665
+
2666
+
2667
+ def _actor_view(agent_session: str | None) -> dict:
2668
+ from ._agent_runtime import detect_agent
2669
+
2670
+ if not agent_session:
2671
+ return {"type": "human", "id": "human", "label": "human"}
2672
+ label = detect_agent(agent_session)
2673
+ if label in ("human", "system"):
2674
+ actor_type = label
2675
+ else:
2676
+ actor_type = "agent"
2677
+ return {
2678
+ "type": actor_type,
2679
+ "id": agent_session,
2680
+ "label": label,
2681
+ }
2682
+
2683
+
2684
+ def _git_commits_for_path(rel_path: str, *, limit: int = 50) -> list[dict]:
2685
+ import subprocess
2686
+
2687
+ root = _project_root()
2688
+ try:
2689
+ out = subprocess.run(
2690
+ [
2691
+ "git",
2692
+ "-C",
2693
+ str(root),
2694
+ "log",
2695
+ f"-n{limit}",
2696
+ "--format=%H%x1f%ct%x1f%s",
2697
+ "--",
2698
+ rel_path,
2699
+ ],
2700
+ capture_output=True,
2701
+ text=True,
2702
+ timeout=5,
2703
+ )
2704
+ except (OSError, subprocess.SubprocessError) as exc:
2705
+ logger.debug("git log failed for %s: %s", rel_path, exc)
2706
+ return []
2707
+ if out.returncode != 0:
2708
+ return []
2709
+ commits: list[dict] = []
2710
+ for raw in out.stdout.splitlines():
2711
+ parts = raw.split("\x1f")
2712
+ if len(parts) != 3:
2713
+ continue
2714
+ sha, ct, subject = parts
2715
+ try:
2716
+ at = int(ct)
2717
+ except ValueError:
2718
+ at = 0
2719
+ commits.append({"sha": sha[:10], "subject": subject, "at": at})
2720
+ return commits
2721
+
2722
+
2723
+ def _git_commits_by_task_id(task_id: str, *, exclude: set[str], limit: int = 50) -> list[dict]:
2724
+ # Actor-agnostic retroactive link: matches commits by message regardless of
2725
+ # source (Hub/terminal/human), without session state or a touch of the .md.
2726
+ # The `([^0-9]|$)` guard stops TASK-5 matching TASK-50.
2727
+ import subprocess
2728
+
2729
+ if not task_id:
2730
+ return []
2731
+ root = _project_root()
2732
+ try:
2733
+ out = subprocess.run(
2734
+ [
2735
+ "git",
2736
+ "-C",
2737
+ str(root),
2738
+ "log",
2739
+ "--all",
2740
+ "-E",
2741
+ f"-n{limit}",
2742
+ "--grep",
2743
+ f"{task_id}([^0-9]|$)",
2744
+ "--format=%H%x1f%ct%x1f%s",
2745
+ ],
2746
+ capture_output=True,
2747
+ text=True,
2748
+ timeout=8,
2749
+ )
2750
+ except (OSError, subprocess.SubprocessError) as exc:
2751
+ logger.debug("git log --grep failed for %s: %s", task_id, exc)
2752
+ return []
2753
+ if out.returncode != 0:
2754
+ return []
2755
+ commits: list[dict] = []
2756
+ for raw in out.stdout.splitlines():
2757
+ parts = raw.split("\x1f")
2758
+ if len(parts) != 3:
2759
+ continue
2760
+ sha, ct, subject = parts
2761
+ if sha[:10] in exclude:
2762
+ continue
2763
+ try:
2764
+ at = int(ct)
2765
+ except ValueError:
2766
+ at = 0
2767
+ commits.append({"sha": sha[:10], "subject": subject, "at": at})
2768
+ return commits
2769
+
2770
+
2771
+ def _git_commits_from_worklog(rel_path: str, *, exclude: set[str], limit: int = 50) -> list[dict]:
2772
+ # Links work-log SHAs that never touched the .md. Validated in ONE indexed
2773
+ # `git cat-file` batch (only type `commit` survives) instead of a per-token
2774
+ # `git show` that can stall the loop and false-match a date↔short-sha collision.
2775
+ import re as _re
2776
+ import subprocess
2777
+
2778
+ root = _project_root()
2779
+ try:
2780
+ text = (Path(root) / rel_path).read_text(encoding="utf-8", errors="ignore")
2781
+ except OSError:
2782
+ return []
2783
+
2784
+ cands: list[str] = []
2785
+ seen: set[str] = set()
2786
+ for cand in _re.findall(r"\b[0-9a-f]{7,40}\b", text):
2787
+ if cand in seen:
2788
+ continue
2789
+ seen.add(cand)
2790
+ cands.append(cand)
2791
+ if len(cands) >= limit:
2792
+ break
2793
+ if not cands:
2794
+ return []
2795
+
2796
+ try:
2797
+ batch = subprocess.run(
2798
+ ["git", "-C", str(root), "cat-file", "--batch-check"],
2799
+ input="\n".join(cands),
2800
+ capture_output=True,
2801
+ text=True,
2802
+ timeout=5,
2803
+ )
2804
+ except (OSError, subprocess.SubprocessError) as exc:
2805
+ logger.debug("git cat-file failed for %s: %s", rel_path, exc)
2806
+ return []
2807
+ if batch.returncode != 0:
2808
+ return []
2809
+
2810
+ # Hit line: "<full-objectname> <type> <size>". Miss/ambiguous line:
2811
+ # "<input> missing" / "<input> ambiguous" — type slot is not "commit".
2812
+ commit_shas = [
2813
+ parts[0]
2814
+ for parts in (line.split() for line in batch.stdout.splitlines())
2815
+ if len(parts) >= 2 and parts[1] == "commit"
2816
+ ]
2817
+ if not commit_shas:
2818
+ return []
2819
+
2820
+ try:
2821
+ res = subprocess.run(
2822
+ ["git", "-C", str(root), "log", "--no-walk", "--format=%H%x1f%ct%x1f%s", *commit_shas],
2823
+ capture_output=True,
2824
+ text=True,
2825
+ timeout=5,
2826
+ )
2827
+ except (OSError, subprocess.SubprocessError) as exc:
2828
+ logger.debug("git log --no-walk failed for %s: %s", rel_path, exc)
2829
+ return []
2830
+ if res.returncode != 0:
2831
+ return []
2832
+
2833
+ out: list[dict] = []
2834
+ for raw in res.stdout.splitlines():
2835
+ parts = raw.split("\x1f")
2836
+ if len(parts) != 3:
2837
+ continue
2838
+ full, ct, subject = parts
2839
+ short = full[:10]
2840
+ if short in exclude:
2841
+ continue
2842
+ try:
2843
+ at = int(ct)
2844
+ except ValueError:
2845
+ at = 0
2846
+ out.append({"sha": short, "subject": subject, "at": at})
2847
+ exclude.add(short)
2848
+ return out
2849
+
2850
+
2851
+ def _worklog_events(rel_path: str) -> list[dict]:
2852
+ # Parse Work Log bullets into timeline events so History and Work Log read as
2853
+ # one chronological story instead of two overlapping surfaces.
2854
+ import re as _re
2855
+ from datetime import datetime, timezone
2856
+
2857
+ root = _project_root()
2858
+ try:
2859
+ text = (Path(root) / rel_path).read_text(encoding="utf-8", errors="ignore")
2860
+ except OSError:
2861
+ return []
2862
+ parsed = parse_task(text)
2863
+ if parsed is None:
2864
+ return []
2865
+ line_re = _re.compile(r"^-\s*(\d{4}-\d{2}-\d{2})\s*\[([^\]]+)\]:\s*(.*)$")
2866
+ out: list[dict] = []
2867
+ for i, ln in enumerate(parsed.work_log_lines):
2868
+ m = line_re.match(ln.strip())
2869
+ if not m:
2870
+ continue
2871
+ date_s, actor, note = m.group(1), m.group(2).strip(), m.group(3).strip()
2872
+ try:
2873
+ # +i keeps same-day bullets in file order under the chronological sort.
2874
+ at = (
2875
+ int(datetime.strptime(date_s, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp())
2876
+ + i
2877
+ )
2878
+ except ValueError:
2879
+ at = 0
2880
+ out.append(
2881
+ {
2882
+ "type": "worklog",
2883
+ "at": at,
2884
+ "actor": {
2885
+ "type": "human" if actor == "human" else "agent",
2886
+ "id": actor,
2887
+ "label": actor,
2888
+ },
2889
+ "text": note,
2890
+ }
2891
+ )
2892
+ return out
2893
+
2894
+
2895
+ @safe_tool
2896
+ def cos_task_history(
2897
+ conn: sqlite3.Connection,
2898
+ *,
2899
+ task_id: str,
2900
+ include_commits: bool = True,
2901
+ limit: int = 200,
2902
+ ) -> str:
2903
+ """Full actor-attributed task history — creation, status transitions, field edits, and git commits."""
2904
+ row = conn.execute(
2905
+ "SELECT file_path FROM tasks WHERE task_id = ?",
2906
+ (task_id,),
2907
+ ).fetchone()
2908
+ if row is None:
2909
+ return fail("not_found", f"task {task_id} not found")
2910
+
2911
+ events: list[dict] = []
2912
+
2913
+ for r in conn.execute(
2914
+ "SELECT old_status, new_status, agent_session, reason, transitioned_at, "
2915
+ "override_reason, override_actor FROM task_status_history "
2916
+ "WHERE task_id = ? ORDER BY transitioned_at",
2917
+ (task_id,),
2918
+ ).fetchall():
2919
+ old, new, sess, reason, at, ov_reason, ov_actor = r
2920
+ events.append(
2921
+ {
2922
+ "type": "created" if not old else "status",
2923
+ "from": old or None,
2924
+ "to": new,
2925
+ "actor": _actor_view(sess),
2926
+ "reason": reason,
2927
+ "override_reason": ov_reason,
2928
+ "override_actor": ov_actor,
2929
+ "at": at,
2930
+ }
2931
+ )
2932
+
2933
+ if _has_table(conn, "task_edit_history"):
2934
+ for r in conn.execute(
2935
+ "SELECT field, old_value, new_value, actor_type, actor_id, source, edited_at "
2936
+ "FROM task_edit_history WHERE task_id = ? ORDER BY edited_at",
2937
+ (task_id,),
2938
+ ).fetchall():
2939
+ field, oldv, newv, atype, aid, src, at = r
2940
+ events.append(
2941
+ {
2942
+ "type": "edit",
2943
+ "field": field,
2944
+ "old_value": oldv,
2945
+ "new_value": newv,
2946
+ "actor": {"type": atype, "id": aid, "label": aid or atype},
2947
+ "source": src,
2948
+ "at": at,
2949
+ }
2950
+ )
2951
+
2952
+ if row[0]:
2953
+ events.extend(_worklog_events(row[0]))
2954
+
2955
+ commits: list[dict] = []
2956
+ if include_commits and row[0]:
2957
+ commits = _git_commits_for_path(row[0], limit=limit)
2958
+ seen_shas = {c["sha"] for c in commits}
2959
+ for c in commits:
2960
+ events.append(
2961
+ {"type": "commit", "sha": c["sha"], "subject": c["subject"], "at": c["at"]}
2962
+ )
2963
+ # Also surface commits referenced in the Work Log (the code commits that
2964
+ # did the work but never touched the md file) so they link WITHOUT a task
2965
+ # id in the commit message — the file-path link only catches md touches.
2966
+ for c in _git_commits_from_worklog(row[0], exclude=seen_shas, limit=limit):
2967
+ seen_shas.add(c["sha"])
2968
+ events.append(
2969
+ {"type": "commit", "sha": c["sha"], "subject": c["subject"], "at": c["at"]}
2970
+ )
2971
+ # The robust, retroactive, actor-agnostic source: commits whose MESSAGE
2972
+ # names this task id (git log --all --grep). Catches Hub/terminal/human
2973
+ # commits the path + work-log sources miss when the id is in the subject.
2974
+ for c in _git_commits_by_task_id(task_id, exclude=seen_shas, limit=limit):
2975
+ events.append(
2976
+ {"type": "commit", "sha": c["sha"], "subject": c["subject"], "at": c["at"]}
2977
+ )
2978
+
2979
+ events.sort(key=lambda e: e.get("at") or 0)
2980
+ if len(events) > limit:
2981
+ events = events[-limit:]
2982
+
2983
+ created = next((e for e in events if e["type"] == "created"), None)
2984
+ edits = [e for e in events if e["type"] == "edit"]
2985
+ contributors = sorted(
2986
+ {
2987
+ e["actor"]["label"]
2988
+ for e in events
2989
+ if e.get("type") in {"created", "status", "edit"} and isinstance(e.get("actor"), dict)
2990
+ }
2991
+ )
2992
+ summary = {
2993
+ "created_by": created["actor"]["label"] if created else None,
2994
+ "created_at": created["at"] if created else None,
2995
+ "last_edited_by": edits[-1]["actor"]["label"] if edits else None,
2996
+ "last_edited_at": edits[-1]["at"] if edits else None,
2997
+ "contributors": contributors,
2998
+ "commit_count": len(commits),
2999
+ }
3000
+
3001
+ return ok(
3002
+ {"task_id": task_id, "events": events, "summary": summary, "count": len(events)},
3003
+ meta={"layer": "tasks", "source": "board_os.cos_task_history"},
3004
+ )
3005
+
3006
+
3007
+ # ---------- cos_task_edit ----------
3008
+
3009
+
3010
+ def _record_task_edit(
3011
+ conn: sqlite3.Connection,
3012
+ *,
3013
+ task_id: str,
3014
+ field: str,
3015
+ old: str | None,
3016
+ new: str | None,
3017
+ actor_type: str,
3018
+ actor_id: str | None,
3019
+ source: str,
3020
+ ) -> None:
3021
+ if not _has_table(conn, "task_edit_history"):
3022
+ return
3023
+ try:
3024
+ conn.execute(
3025
+ "INSERT INTO task_edit_history "
3026
+ "(task_id, field, old_value, new_value, actor_type, actor_id, source, edited_at) "
3027
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
3028
+ (task_id, field, old, new, actor_type, actor_id, source, int(time.time())),
3029
+ )
3030
+ conn.commit()
3031
+ except sqlite3.Error as exc:
3032
+ logger.debug("task_edit_history insert failed for %s.%s: %s", task_id, field, exc)
3033
+
3034
+
3035
+ _WORKLOG_HEADING_RE = re.compile(r"(?im)^##[ \t]+Work Log[ \t]*$")
3036
+
3037
+
3038
+ def _worklog_span(body: str) -> str:
3039
+ m = _WORKLOG_HEADING_RE.search(body)
3040
+ if m is None:
3041
+ return ""
3042
+ nxt = re.search(r"(?m)^## ", body[m.end() :])
3043
+ end = m.end() + nxt.start() if nxt else len(body)
3044
+ return body[m.start() : end].rstrip("\n")
3045
+
3046
+
3047
+ def _strip_leading_h1(body: str) -> str:
3048
+ return re.sub(r"^\s*#\s+.+\n+", "", body.lstrip("\n")).strip()
3049
+
3050
+
3051
+ @safe_tool
3052
+ def cos_task_edit(
3053
+ conn: sqlite3.Connection,
3054
+ *,
3055
+ task_id: str,
3056
+ title: str | None = None,
3057
+ priority: str | None = None,
3058
+ swimlane: str | None = None,
3059
+ appetite: str | None = None,
3060
+ epic: str | None = None,
3061
+ labels: list[str] | None = None,
3062
+ body: str | None = None,
3063
+ actor_type: str = "agent",
3064
+ actor_id: str | None = None,
3065
+ source: str = "mcp",
3066
+ ) -> str:
3067
+ """Edit a task's frontmatter fields and/or body, recording each change to the actor-attributed edit history."""
3068
+ from board_os.parser import _FRONTMATTER_RE, extract_frontmatter
3069
+
3070
+ row = conn.execute(
3071
+ "SELECT file_path FROM tasks WHERE task_id = ?",
3072
+ (task_id,),
3073
+ ).fetchone()
3074
+ if row is None or not row[0]:
3075
+ return fail("not_found", f"task {task_id} not found")
3076
+ file_path = _project_root() / row[0]
3077
+ if not file_path.exists():
3078
+ return fail("not_found", f"file missing: {file_path}")
3079
+
3080
+ content = file_path.read_text(encoding="utf-8")
3081
+ m = _FRONTMATTER_RE.match(content)
3082
+ fm = extract_frontmatter(content)
3083
+ if m is None or fm is None:
3084
+ return fail("validation", f"{task_id} is not in lean frontmatter format")
3085
+ current_body = m.group("body")
3086
+
3087
+ config = _current_config()
3088
+ if swimlane is not None and config is not None and swimlane not in config.swimlane_ids:
3089
+ return fail(
3090
+ "validation",
3091
+ f"swimlane {swimlane!r} not in config; valid: {sorted(config.swimlane_ids)}",
3092
+ )
3093
+ if priority is not None and priority not in PRIORITY_ENUM:
3094
+ return fail("validation", f"priority {priority!r} not in {sorted(PRIORITY_ENUM)}")
3095
+ if appetite is not None and not APPETITE_RE.match(appetite):
3096
+ return fail("validation", f"appetite {appetite!r} bad shape")
3097
+ if title is not None and not title.strip():
3098
+ return fail("validation", "title must be non-empty")
3099
+ if labels is not None:
3100
+ for lbl in labels:
3101
+ if lbl in KIND_ENUM:
3102
+ return fail(
3103
+ "validation",
3104
+ f"label {lbl!r} collides with KIND_ENUM — use kind, not labels",
3105
+ )
3106
+
3107
+ resolved_actor = actor_id or _resolve_attribution(None)
3108
+ changed: list[str] = []
3109
+
3110
+ def _maybe(field: str, new_val: object) -> None:
3111
+ if new_val is None or new_val == fm.get(field):
3112
+ return
3113
+ old_val = fm.get(field)
3114
+ fm[field] = new_val
3115
+ _record_task_edit(
3116
+ conn,
3117
+ task_id=task_id,
3118
+ field=field,
3119
+ old=None if old_val is None else str(old_val),
3120
+ new=str(new_val),
3121
+ actor_type=actor_type,
3122
+ actor_id=resolved_actor,
3123
+ source=source,
3124
+ )
3125
+ changed.append(field)
3126
+
3127
+ _maybe("title", title)
3128
+ _maybe("priority", priority)
3129
+ _maybe("swimlane", swimlane)
3130
+ _maybe("appetite", appetite)
3131
+ _maybe("epic", epic)
3132
+
3133
+ if labels is not None and list(labels) != list(fm.get("labels") or []):
3134
+ old_labels = fm.get("labels") or []
3135
+ fm["labels"] = list(labels)
3136
+ _record_task_edit(
3137
+ conn,
3138
+ task_id=task_id,
3139
+ field="labels",
3140
+ old=", ".join(str(x) for x in old_labels),
3141
+ new=", ".join(labels),
3142
+ actor_type=actor_type,
3143
+ actor_id=resolved_actor,
3144
+ source=source,
3145
+ )
3146
+ changed.append("labels")
3147
+
3148
+ new_body = current_body
3149
+ if body is not None:
3150
+ incoming = body
3151
+ # The board drawer's body is a snapshot; a cos_work_log_append can land
3152
+ # between its fetch and this save. Swap the client's (possibly stale)
3153
+ # "## Work Log" for the FRESH on-disk section in place, so a concurrent
3154
+ # append is never lost and the section never reorders.
3155
+ fresh_wl = _worklog_span(current_body)
3156
+ if fresh_wl:
3157
+ stale_wl = _worklog_span(incoming)
3158
+ if stale_wl and stale_wl != fresh_wl:
3159
+ incoming = incoming.replace(stale_wl, fresh_wl, 1)
3160
+ elif not stale_wl:
3161
+ incoming = incoming.rstrip("\n") + "\n\n" + fresh_wl + "\n"
3162
+ # Compare H1-normalized: the drawer strips the leading H1 (the write
3163
+ # path re-prepends the canonical one), so a body that differs only by
3164
+ # that H1 must not record a phantom body change.
3165
+ if _strip_leading_h1(incoming) != _strip_leading_h1(current_body):
3166
+ import hashlib
3167
+
3168
+ new_body = incoming
3169
+ _record_task_edit(
3170
+ conn,
3171
+ task_id=task_id,
3172
+ field="body",
3173
+ old=hashlib.sha1(current_body.encode("utf-8")).hexdigest()[:12],
3174
+ new=hashlib.sha1(incoming.encode("utf-8")).hexdigest()[:12],
3175
+ actor_type=actor_type,
3176
+ actor_id=resolved_actor,
3177
+ source=source,
3178
+ )
3179
+ changed.append("body")
3180
+
3181
+ if not changed:
3182
+ return ok(
3183
+ {"task_id": task_id, "changed": []},
3184
+ meta={"layer": "tasks", "source": "board_os.cos_task_edit"},
3185
+ )
3186
+
3187
+ # Normalise the canonical H1 (`# TASK-NNN: <title>`) from the current
3188
+ # frontmatter title: a panel body edit arrives H1-stripped (the drawer
3189
+ # removes it for display) and a title change must propagate to the H1.
3190
+ # Strip any leading H1 from the incoming body, then prepend the canonical.
3191
+ title_now = str(fm.get("title") or task_id)
3192
+ body_inner = re.sub(r"^\s*#\s+.+\n+", "", new_body.lstrip("\n"))
3193
+ new_content = (
3194
+ _render_lean_frontmatter(fm)
3195
+ + f"\n\n# {task_id}: {title_now}\n\n"
3196
+ + body_inner.strip("\n")
3197
+ + "\n"
3198
+ )
3199
+ file_path.write_text(new_content, encoding="utf-8")
3200
+ sync_one(conn, file_path, project_root=_project_root())
3201
+
3202
+ return ok(
3203
+ {
3204
+ "task_id": task_id,
3205
+ "changed": changed,
3206
+ "actor": {"type": actor_type, "id": resolved_actor},
3207
+ },
3208
+ meta={"layer": "tasks", "source": "board_os.cos_task_edit"},
3209
+ )
3210
+
3211
+
3212
+ # ---------- Helpers ----------
3213
+
3214
+
3215
+ def _parse_since(since: str) -> float:
3216
+ m = re.match(r"^(\d+)([mhdw])$", since)
3217
+ if not m:
3218
+ return 24.0
3219
+ n, unit = int(m.group(1)), m.group(2)
3220
+ return {"m": n / 60.0, "h": float(n), "d": n * 24.0, "w": n * 24.0 * 7.0}[unit]
3221
+
3222
+
3223
+ # ---------- Cycle validation tool (exposed for hooks) ----------
3224
+
3225
+
3226
+ def check_cycle(conn: sqlite3.Connection, task_id: str, new_deps: list[str]) -> list[str]:
3227
+ """Thin passthrough to workflow.validate_dependencies_no_cycle."""
3228
+ return validate_dependencies_no_cycle(conn, task_id, new_deps)