coding-os 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (1304) hide show
  1. adapters/claude/README.md +6 -0
  2. adapters/claude/_install_helpers/extract_stacks.py +43 -0
  3. adapters/claude/_install_helpers/update_mcp_json.py +75 -0
  4. adapters/claude/adapter.yaml +164 -0
  5. adapters/claude/hooks/README.md +40 -0
  6. adapters/claude/hooks/agent_memory_sync.py +131 -0
  7. adapters/claude/hooks/ensure-agent-memory-link.sh +36 -0
  8. adapters/claude/hooks/sync-agent-memory.sh +21 -0
  9. adapters/claude/install.sh +86 -0
  10. adapters/claude/sdk_dispatcher.py +871 -0
  11. adapters/claude/settings.local.template.json +31 -0
  12. adapters/claude/settings.template.json +808 -0
  13. adapters/claude/update_mcp_json.py +85 -0
  14. adapters/codex/adapter.yaml +254 -0
  15. adapters/codex/chat_provider.py +230 -0
  16. adapters/codex/commands/formula-f1.md +129 -0
  17. adapters/codex/commands/formula-f10.md +100 -0
  18. adapters/codex/commands/formula-f11.md +123 -0
  19. adapters/codex/commands/formula-f2.md +139 -0
  20. adapters/codex/commands/formula-f3.md +127 -0
  21. adapters/codex/commands/formula-f4.md +101 -0
  22. adapters/codex/commands/formula-f5.md +135 -0
  23. adapters/codex/commands/formula-f6.md +147 -0
  24. adapters/codex/commands/formula-f7.md +111 -0
  25. adapters/codex/commands/formula-f8.md +133 -0
  26. adapters/codex/commands/formula-f9.md +112 -0
  27. adapters/codex/enable_codex_hooks.py +94 -0
  28. adapters/codex/ensure_codex_mcp.py +124 -0
  29. adapters/codex/hooks/codex-merge-hook-output.py +72 -0
  30. adapters/codex/hooks/codex-normalize-edit.py +96 -0
  31. adapters/codex/hooks/codex-postedit-dispatch.sh +75 -0
  32. adapters/codex/hooks/codex-posttool-dispatch.sh +70 -0
  33. adapters/codex/hooks/codex-preedit-dispatch.sh +83 -0
  34. adapters/codex/hooks/codex-pretool-dispatch.sh +82 -0
  35. adapters/codex/hooks/codex-sessionend-dispatch.sh +20 -0
  36. adapters/codex/hooks/codex-sessionstart-dispatch.sh +68 -0
  37. adapters/codex/hooks/codex-stop-dispatch.sh +73 -0
  38. adapters/codex/hooks/codex-userpromptsubmit-dispatch.sh +74 -0
  39. adapters/codex/hooks.template.json +208 -0
  40. adapters/codex/install.sh +83 -0
  41. adapters/codex/sdk_dispatcher.py +449 -0
  42. board_os/__init__.py +39 -0
  43. board_os/_agent_runtime.py +256 -0
  44. board_os/config.py +421 -0
  45. board_os/git_coherence.py +107 -0
  46. board_os/hub_adapter_manifest.py +140 -0
  47. board_os/mcp_tools.py +3228 -0
  48. board_os/migration.py +166 -0
  49. board_os/parser.py +317 -0
  50. board_os/presence.py +156 -0
  51. board_os/sync.py +320 -0
  52. board_os/transition_gates.py +224 -0
  53. board_os/transition_gates_cli.py +272 -0
  54. board_os/transition_gates_validator.py +551 -0
  55. board_os/verify_suites.py +126 -0
  56. board_os/verify_suites_cli.py +328 -0
  57. board_os/workflow.py +967 -0
  58. cli/__init__.py +0 -0
  59. cli/_data_types.py +248 -0
  60. cli/_init_helpers.py +587 -0
  61. cli/_resources.py +100 -0
  62. cli/adapter_registry.py +239 -0
  63. cli/add_stack.py +315 -0
  64. cli/aggregator.py +438 -0
  65. cli/board_commands.py +1218 -0
  66. cli/brain_commands.py +255 -0
  67. cli/cognition.py +345 -0
  68. cli/config_composer.py +349 -0
  69. cli/core_version.py +41 -0
  70. cli/cron_commands.py +278 -0
  71. cli/db_reset.py +298 -0
  72. cli/doc_commands.py +111 -0
  73. cli/doctor.py +2953 -0
  74. cli/doctor_board.py +365 -0
  75. cli/doctor_extras.py +1121 -0
  76. cli/doctor_graph.py +608 -0
  77. cli/doctor_tokens.py +254 -0
  78. cli/graph_commands.py +1265 -0
  79. cli/hook_renderer.py +393 -0
  80. cli/hub_commands.py +580 -0
  81. cli/list_adapters.py +79 -0
  82. cli/list_stacks.py +105 -0
  83. cli/logs_commands.py +89 -0
  84. cli/main.py +3070 -0
  85. cli/materialize_file.py +65 -0
  86. cli/mcp_start.py +153 -0
  87. cli/module_commands.py +513 -0
  88. cli/pr_commands.py +2024 -0
  89. cli/preset_commands.py +126 -0
  90. cli/preset_registry.py +171 -0
  91. cli/project_overrides.py +119 -0
  92. cli/registry.py +365 -0
  93. cli/remove_stack.py +492 -0
  94. cli/renderer.py +620 -0
  95. cli/setup.py +464 -0
  96. cli/skill_commands.py +689 -0
  97. cli/skill_registry.py +235 -0
  98. cli/skills_list.py +332 -0
  99. cli/stack_lint.py +351 -0
  100. cli/stack_registry.py +688 -0
  101. cli/subsystems.py +335 -0
  102. cli/sync_all.py +310 -0
  103. cli/tail_command.py +410 -0
  104. cli/update.py +608 -0
  105. cli/verify_since_edit.py +439 -0
  106. coding_os-0.3.2.dist-info/METADATA +508 -0
  107. coding_os-0.3.2.dist-info/RECORD +1304 -0
  108. coding_os-0.3.2.dist-info/WHEEL +5 -0
  109. coding_os-0.3.2.dist-info/entry_points.txt +5 -0
  110. coding_os-0.3.2.dist-info/licenses/LICENSE +201 -0
  111. coding_os-0.3.2.dist-info/top_level.txt +10 -0
  112. core/__init__.py +0 -0
  113. core/board_os/__init__.py +39 -0
  114. core/board_os/_agent_runtime.py +256 -0
  115. core/board_os/config.py +421 -0
  116. core/board_os/git_coherence.py +107 -0
  117. core/board_os/hub_adapter_manifest.py +140 -0
  118. core/board_os/mcp_tools.py +3228 -0
  119. core/board_os/migration.py +166 -0
  120. core/board_os/parser.py +317 -0
  121. core/board_os/presence.py +156 -0
  122. core/board_os/sync.py +320 -0
  123. core/board_os/transition-gates.yaml +176 -0
  124. core/board_os/transition_gates.py +224 -0
  125. core/board_os/transition_gates_cli.py +272 -0
  126. core/board_os/transition_gates_validator.py +551 -0
  127. core/board_os/verify-suites.yaml +113 -0
  128. core/board_os/verify_suites.py +126 -0
  129. core/board_os/verify_suites_cli.py +328 -0
  130. core/board_os/workflow.py +967 -0
  131. core/commands/board.md +27 -0
  132. core/commands/classify.md +23 -0
  133. core/commands/compose.md +23 -0
  134. core/commands/daily.md +31 -0
  135. core/commands/diagnose.md +7 -0
  136. core/commands/memory-search.md +23 -0
  137. core/commands/new-project.md +33 -0
  138. core/commands/retro.md +38 -0
  139. core/commands/review.md +14 -0
  140. core/commands/task.md +17 -0
  141. core/commands/verify.md +34 -0
  142. core/docs/thinking_os-final-edition.md +1449 -0
  143. core/doctor-config.yaml +74 -0
  144. core/graph_os/__init__.py +29 -0
  145. core/graph_os/backend.py +233 -0
  146. core/graph_os/backends/__init__.py +13 -0
  147. core/graph_os/backends/sqlite_backend.py +1053 -0
  148. core/graph_os/bench/__init__.py +17 -0
  149. core/graph_os/bench/fixtures.py +56 -0
  150. core/graph_os/bench/harness.py +95 -0
  151. core/graph_os/bench/persian_precision.py +142 -0
  152. core/graph_os/bench/scale_500k.py +120 -0
  153. core/graph_os/bench/token_cost.py +170 -0
  154. core/graph_os/bench/viewer_fps.py +110 -0
  155. core/graph_os/communities.py +410 -0
  156. core/graph_os/enterprise.py +218 -0
  157. core/graph_os/entry_points.py +226 -0
  158. core/graph_os/extractors/__init__.py +25 -0
  159. core/graph_os/extractors/code_generic.py +914 -0
  160. core/graph_os/extractors/code_go.py +1422 -0
  161. core/graph_os/extractors/code_json.py +340 -0
  162. core/graph_os/extractors/code_php.py +979 -0
  163. core/graph_os/extractors/code_python.py +1454 -0
  164. core/graph_os/extractors/code_shell.py +538 -0
  165. core/graph_os/extractors/code_toml.py +302 -0
  166. core/graph_os/extractors/code_ts.py +1665 -0
  167. core/graph_os/extractors/code_yaml.py +394 -0
  168. core/graph_os/extractors/contracts.py +1592 -0
  169. core/graph_os/extractors/md_links.py +890 -0
  170. core/graph_os/extractors/task_deps.py +345 -0
  171. core/graph_os/groups/__init__.py +22 -0
  172. core/graph_os/groups/cross_repo.py +156 -0
  173. core/graph_os/groups/manifest.py +141 -0
  174. core/graph_os/ingest/__init__.py +19 -0
  175. core/graph_os/ingest/base.py +306 -0
  176. core/graph_os/ingest/github.py +112 -0
  177. core/graph_os/ingest/zip.py +95 -0
  178. core/graph_os/toolchain.py +393 -0
  179. core/graph_os/tools/__init__.py +9 -0
  180. core/graph_os/tools/graph.py +5573 -0
  181. core/graph_os/tools/reindex_dispatch.py +730 -0
  182. core/graph_os/tree_sitter_overlay.py +235 -0
  183. core/graph_os/types.py +252 -0
  184. core/graph_os/vec_index.py +277 -0
  185. core/graph_os/viewer/__init__.py +12 -0
  186. core/graph_os/viewer/exporter.py +93 -0
  187. core/graph_os/viewer/template.py +189 -0
  188. core/hooks/_helpers/_paths.py +40 -0
  189. core/hooks/_helpers/advance_role.py +72 -0
  190. core/hooks/_helpers/auto_compose.py +228 -0
  191. core/hooks/_helpers/auto_validate_lessons.py +55 -0
  192. core/hooks/_helpers/branch_guard_check.py +796 -0
  193. core/hooks/_helpers/check_commit_message.py +108 -0
  194. core/hooks/_helpers/check_dangerous_rm.py +80 -0
  195. core/hooks/_helpers/check_git_bypass.py +154 -0
  196. core/hooks/_helpers/check_git_destructive.py +77 -0
  197. core/hooks/_helpers/check_settings_write.py +97 -0
  198. core/hooks/_helpers/consume_override.py +51 -0
  199. core/hooks/_helpers/context_budget.py +77 -0
  200. core/hooks/_helpers/cos_say_json.py +103 -0
  201. core/hooks/_helpers/destructive_edit_check.py +163 -0
  202. core/hooks/_helpers/detect_status_transition.py +82 -0
  203. core/hooks/_helpers/digest_regen.py +56 -0
  204. core/hooks/_helpers/doc_sync_check.py +498 -0
  205. core/hooks/_helpers/drain_embedding_outbox.py +52 -0
  206. core/hooks/_helpers/extract_additional_context.py +51 -0
  207. core/hooks/_helpers/extract_commit_msg_arg.py +74 -0
  208. core/hooks/_helpers/git_command_parse.py +424 -0
  209. core/hooks/_helpers/git_settings_fields.py +47 -0
  210. core/hooks/_helpers/graph_context_match.py +37 -0
  211. core/hooks/_helpers/graph_marker_check.py +70 -0
  212. core/hooks/_helpers/jit_recall.py +56 -0
  213. core/hooks/_helpers/json_field.py +41 -0
  214. core/hooks/_helpers/narrative_signal.py +59 -0
  215. core/hooks/_helpers/observation_count.py +31 -0
  216. core/hooks/_helpers/pre_commit_batch.py +177 -0
  217. core/hooks/_helpers/pre_commit_fake_input.py +42 -0
  218. core/hooks/_helpers/presence_gc.py +102 -0
  219. core/hooks/_helpers/presence_write.py +167 -0
  220. core/hooks/_helpers/recover_indirect.py +35 -0
  221. core/hooks/_helpers/routing_evolution.py +104 -0
  222. core/hooks/_helpers/session_recap.py +72 -0
  223. core/hooks/_helpers/skill_primer.py +229 -0
  224. core/hooks/_helpers/task_sync.py +59 -0
  225. core/hooks/_helpers/tool_failure_capture.py +147 -0
  226. core/hooks/_helpers/trajectory_autosnap.py +278 -0
  227. core/hooks/_helpers/trajectory_startup.py +62 -0
  228. core/hooks/_helpers/turn_summary.py +82 -0
  229. core/hooks/_helpers/validate_task_frontmatter.py +98 -0
  230. core/hooks/_helpers/wip_limit_check.py +103 -0
  231. core/hooks/_helpers/wip_lines.py +53 -0
  232. core/hooks/_helpers/work_log_append.py +89 -0
  233. core/hooks/_helpers/wrap_dispatch_output.py +82 -0
  234. core/hooks/advance-role.sh +48 -0
  235. core/hooks/agent-presence.sh +179 -0
  236. core/hooks/auto-brain-decay.sh +184 -0
  237. core/hooks/auto-compose-roles.sh +83 -0
  238. core/hooks/auto-graph-reconcile-shell.sh +119 -0
  239. core/hooks/auto-regen-doc-index.sh +120 -0
  240. core/hooks/auto-reindex-docs.sh +130 -0
  241. core/hooks/auto-task-sync.sh +56 -0
  242. core/hooks/auto-trace-rotate.sh +88 -0
  243. core/hooks/block-bad-patterns.sh +212 -0
  244. core/hooks/block-dangerous-commands.sh +182 -0
  245. core/hooks/block-hardcoded-literals.sh +90 -0
  246. core/hooks/block-migration-conflict.sh +114 -0
  247. core/hooks/block-protected-files.sh +129 -0
  248. core/hooks/block-secrets.sh +185 -0
  249. core/hooks/block-shared-tree-edit.sh +75 -0
  250. core/hooks/block-uv-heredoc.sh +78 -0
  251. core/hooks/branch-guard.sh +122 -0
  252. core/hooks/capture-observation.sh +76 -0
  253. core/hooks/capture-tool-failure.sh +24 -0
  254. core/hooks/capture-work-log.sh +89 -0
  255. core/hooks/check-agents-md-refs.sh +75 -0
  256. core/hooks/check-agents-md-size.sh +49 -0
  257. core/hooks/check-capture-worked.sh +148 -0
  258. core/hooks/check-doc-size.sh +61 -0
  259. core/hooks/check-mcp-extras.sh +92 -0
  260. core/hooks/check-state.sh +87 -0
  261. core/hooks/classify-task-mode.sh +103 -0
  262. core/hooks/cos-env.sh +1301 -0
  263. core/hooks/drain-embedding-outbox.sh +24 -0
  264. core/hooks/enforce-anti-ambiguity.sh +74 -0
  265. core/hooks/enforce-commit-message.sh +73 -0
  266. core/hooks/enforce-doc-anchor.sh +224 -0
  267. core/hooks/enforce-doc-sync.sh +206 -0
  268. core/hooks/enforce-graph-context.sh +89 -0
  269. core/hooks/enforce-graph-first-read.sh +94 -0
  270. core/hooks/enforce-memory-check.sh +128 -0
  271. core/hooks/enforce-rename-plan.sh +78 -0
  272. core/hooks/enforce-scaffold-boundary.sh +68 -0
  273. core/hooks/enforce-skill.sh +125 -0
  274. core/hooks/enforce-task-body.sh +52 -0
  275. core/hooks/enforce-task-start.sh +81 -0
  276. core/hooks/enforce-task-transition.sh +75 -0
  277. core/hooks/enforce-template.sh +143 -0
  278. core/hooks/enforce-verify.sh +112 -0
  279. core/hooks/enforce-wip-limit.sh +43 -0
  280. core/hooks/enforce-zoom.sh +69 -0
  281. core/hooks/ensure-hub-up.sh +67 -0
  282. core/hooks/inject-mcp-caller-session.sh +70 -0
  283. core/hooks/jit-recall.sh +65 -0
  284. core/hooks/link-commit-to-task.sh +143 -0
  285. core/hooks/lint-task.sh +40 -0
  286. core/hooks/nudge-docs-first.sh +71 -0
  287. core/hooks/nudge-git-mode.sh +29 -0
  288. core/hooks/nudge-graph-os.sh +118 -0
  289. core/hooks/nudge-learn-narrative.sh +36 -0
  290. core/hooks/nudge-model-routing.sh +32 -0
  291. core/hooks/nudge-reentry.sh +101 -0
  292. core/hooks/nudge-reuse-first.sh +68 -0
  293. core/hooks/nudge-task-discovery.sh +81 -0
  294. core/hooks/nudge-thinking-os.sh +109 -0
  295. core/hooks/pr-reap.sh +23 -0
  296. core/hooks/reclaim-sweep.sh +58 -0
  297. core/hooks/record-verify-auto.sh +77 -0
  298. core/hooks/record-verify.sh +74 -0
  299. core/hooks/regen-reminder.sh +104 -0
  300. core/hooks/registry.yaml +1262 -0
  301. core/hooks/remind-daily.sh +27 -0
  302. core/hooks/remind-dogfood.sh +70 -0
  303. core/hooks/remind-learn-validate.sh +94 -0
  304. core/hooks/rules-primer.sh +50 -0
  305. core/hooks/search-enforce-inventory.sh +108 -0
  306. core/hooks/search-verify-remaining.sh +132 -0
  307. core/hooks/session-context.sh +729 -0
  308. core/hooks/session-end.sh +145 -0
  309. core/hooks/session-skill-primer.sh +43 -0
  310. core/hooks/snapshot-transcript.sh +56 -0
  311. core/hooks/sync-task-current.sh +85 -0
  312. core/hooks/test-first-reminder.sh +120 -0
  313. core/hooks/test-governor.sh +173 -0
  314. core/hooks/thinking_os-gate.sh +52 -0
  315. core/hooks/track-backtrack.sh +35 -0
  316. core/hooks/track-discovery.sh +121 -0
  317. core/hooks/track-skill.sh +53 -0
  318. core/hooks/validate-task-frontmatter.sh +49 -0
  319. core/hooks/verify-rename-callers.sh +119 -0
  320. core/hooks/warn-abandoned-task.sh +99 -0
  321. core/hooks/warn-destructive-edit.sh +64 -0
  322. core/hooks/warn-diff-size.sh +43 -0
  323. core/hooks/warn-graph-empty.sh +81 -0
  324. core/hooks/warn-mcp-down.sh +190 -0
  325. core/hooks/write-state.sh +55 -0
  326. core/logging_os/__init__.py +33 -0
  327. core/logging_os/api.py +127 -0
  328. core/logging_os/bridge.py +80 -0
  329. core/logging_os/config.py +172 -0
  330. core/logging_os/fingerprint.py +25 -0
  331. core/logging_os/redact.py +53 -0
  332. core/logging_os/render.py +83 -0
  333. core/logging_os/sinks.py +164 -0
  334. core/rules/anti-overengineering.md +44 -0
  335. core/rules/api-contract-discipline.md +41 -0
  336. core/rules/dimension-registry.md +155 -0
  337. core/rules/git-workflow.md +57 -0
  338. core/rules/memory.md +46 -0
  339. core/rules/model-routing.md +22 -0
  340. core/rules/skill-enforcement.md +75 -0
  341. core/rules/test-discipline.md +38 -0
  342. core/rules/thinking_os.md +48 -0
  343. core/rules/transparency-banner.md +37 -0
  344. core/runtime_paths.yaml +36 -0
  345. core/scaffold_manifest.json +14430 -0
  346. core/scheduled/__init__.py +0 -0
  347. core/scheduled/_activity.py +126 -0
  348. core/scheduled/_state.py +113 -0
  349. core/scheduled/config.py +86 -0
  350. core/scheduled/dep_reconcile.py +135 -0
  351. core/scheduled/error_sweep.py +137 -0
  352. core/scheduled/nightly.py +930 -0
  353. core/scheduled/responsive_extract.py +65 -0
  354. core/schemas/adapter.schema.json +269 -0
  355. core/schemas/preset.schema.json +50 -0
  356. core/schemas/skill.schema.json +81 -0
  357. core/schemas/stack.schema.json +404 -0
  358. core/scripts/_lib.sh +9 -0
  359. core/scripts/docs-lint.sh +228 -0
  360. core/scripts/docs-nav-fix.sh +133 -0
  361. core/scripts/docs-staleness-check.sh +154 -0
  362. core/scripts/install-adapter.sh +266 -0
  363. core/scripts/link-stack-skills.sh +52 -0
  364. core/scripts/log-latest.sh +106 -0
  365. core/scripts/log-search.sh +89 -0
  366. core/scripts/log-write.sh +134 -0
  367. core/scripts/ref-resolve.sh +71 -0
  368. core/skills/a11y/SKILL.md +305 -0
  369. core/skills/a11y/assets/a11y-checklist.md +137 -0
  370. core/skills/a11y/references/aria-and-focus.md +247 -0
  371. core/skills/a11y/references/rn-accessibility.md +343 -0
  372. core/skills/a11y/references/screen-reader-testing.md +190 -0
  373. core/skills/agent-memory/SKILL.md +191 -0
  374. core/skills/agent-memory/assets/memory-checklist.md +21 -0
  375. core/skills/agent-memory/references/memory-recipes.md +57 -0
  376. core/skills/api-design/SKILL.md +232 -0
  377. core/skills/api-design/assets/api-design-checklist.md +110 -0
  378. core/skills/api-design/references/error-envelope.md +381 -0
  379. core/skills/api-design/references/idempotency-pagination.md +312 -0
  380. core/skills/api-design/references/rest-contracts.md +426 -0
  381. core/skills/auth-patterns/SKILL.md +352 -0
  382. core/skills/auth-patterns/assets/auth-checklist.md +118 -0
  383. core/skills/auth-patterns/references/jwt-and-service-tokens.md +343 -0
  384. core/skills/auth-patterns/references/passkeys-2fa.md +289 -0
  385. core/skills/auth-patterns/references/sessions-vs-jwt.md +230 -0
  386. core/skills/auth-patterns/scripts/cookie-flag-check.py +146 -0
  387. core/skills/backend-fundamentals/SKILL.md +238 -0
  388. core/skills/backend-fundamentals/assets/backend-checklist.md +27 -0
  389. core/skills/backend-fundamentals/references/backend-patterns.md +56 -0
  390. core/skills/backend-fundamentals/scripts/check_layering.py +83 -0
  391. core/skills/clean-code/SKILL.md +642 -0
  392. core/skills/clean-code/scripts/audit-fail-closed.py +167 -0
  393. core/skills/codebase-explorer/SKILL.md +89 -0
  394. core/skills/codebase-explorer/assets/reading-checklist.md +24 -0
  395. core/skills/codebase-explorer/references/reading-strategies.md +52 -0
  396. core/skills/codebase-explorer/scripts/outline.py +99 -0
  397. core/skills/db-design/SKILL.md +327 -0
  398. core/skills/db-design/assets/migration-template.sql +49 -0
  399. core/skills/db-design/references/migration-discipline.md +290 -0
  400. core/skills/db-design/references/postgres-patterns.md +340 -0
  401. core/skills/db-design/scripts/migration-safety.sh +150 -0
  402. core/skills/deployment-cicd/SKILL.md +260 -0
  403. core/skills/deployment-cicd/assets/deploy-checklist.md +26 -0
  404. core/skills/deployment-cicd/references/pipeline-and-release.md +54 -0
  405. core/skills/deployment-cicd/scripts/lint_workflow.py +79 -0
  406. core/skills/docker/SKILL.md +114 -0
  407. core/skills/docker/assets/dockerfile-checklist.md +31 -0
  408. core/skills/docker/references/compose-patterns.md +66 -0
  409. core/skills/docker/references/dockerfile-optimization.md +64 -0
  410. core/skills/docker/scripts/lint_dockerfile.sh +48 -0
  411. core/skills/docker/versions.json +16 -0
  412. core/skills/end-to-end-testing/SKILL.md +101 -0
  413. core/skills/end-to-end-testing/assets/e2e-checklist.md +23 -0
  414. core/skills/end-to-end-testing/references/maestro.md +63 -0
  415. core/skills/end-to-end-testing/references/playwright.md +68 -0
  416. core/skills/end-to-end-testing/scripts/lint_e2e.py +88 -0
  417. core/skills/end-to-end-testing/versions.json +16 -0
  418. core/skills/frontend-design/SKILL.md +76 -0
  419. core/skills/frontend-design/assets/design-checklist.md +29 -0
  420. core/skills/frontend-design/references/design-principles.md +65 -0
  421. core/skills/frontend-design/scripts/check_contrast.py +89 -0
  422. core/skills/frontend-fundamentals/SKILL.md +213 -0
  423. core/skills/frontend-fundamentals/assets/frontend-checklist.md +25 -0
  424. core/skills/frontend-fundamentals/references/rendering-and-state.md +66 -0
  425. core/skills/frontend-fundamentals/scripts/check_frontend.py +86 -0
  426. core/skills/graph-explorer/SKILL.md +215 -0
  427. core/skills/graph-explorer/scripts/explain-impact.sh +64 -0
  428. core/skills/graphql/SKILL.md +187 -0
  429. core/skills/grpc-microservices/SKILL.md +174 -0
  430. core/skills/hexagonal-architecture/SKILL.md +199 -0
  431. core/skills/hexagonal-architecture/assets/folder-scaffold.md +233 -0
  432. core/skills/hexagonal-architecture/references/anti-patterns.md +129 -0
  433. core/skills/hexagonal-architecture/references/go-fiber-layout.md +429 -0
  434. core/skills/hexagonal-architecture/references/python-fastapi-layout.md +453 -0
  435. core/skills/hexagonal-architecture/references/react-native-layout.md +428 -0
  436. core/skills/i18n/SKILL.md +126 -0
  437. core/skills/incident-response/SKILL.md +225 -0
  438. core/skills/incident-response/assets/incident-checklist.md +29 -0
  439. core/skills/incident-response/references/severity-and-runbook.md +53 -0
  440. core/skills/incident-response/scripts/classify_severity.py +86 -0
  441. core/skills/linux-sysadmin/SKILL.md +115 -0
  442. core/skills/linux-sysadmin/assets/hardening-checklist.md +29 -0
  443. core/skills/linux-sysadmin/references/ssh-hardening.md +62 -0
  444. core/skills/linux-sysadmin/references/systemd-and-services.md +77 -0
  445. core/skills/linux-sysadmin/scripts/triage.sh +50 -0
  446. core/skills/linux-sysadmin/versions.json +17 -0
  447. core/skills/llm-patterns/SKILL.md +410 -0
  448. core/skills/llm-patterns/assets/llm-feature-checklist.md +26 -0
  449. core/skills/llm-patterns/references/rag-and-evals.md +59 -0
  450. core/skills/llm-patterns/scripts/estimate_tokens.py +75 -0
  451. core/skills/messaging-queues/SKILL.md +142 -0
  452. core/skills/mobile-fundamentals/SKILL.md +406 -0
  453. core/skills/mobile-fundamentals/assets/mobile-launch-checklist.md +130 -0
  454. core/skills/mobile-fundamentals/references/navigation-and-deep-links.md +337 -0
  455. core/skills/mobile-fundamentals/references/offline-sync.md +339 -0
  456. core/skills/node-backend/SKILL.md +114 -0
  457. core/skills/node-backend/assets/node-checklist.md +25 -0
  458. core/skills/node-backend/references/async-and-errors.md +67 -0
  459. core/skills/node-backend/references/event-loop.md +65 -0
  460. core/skills/node-backend/scripts/check_package.py +78 -0
  461. core/skills/node-backend/versions.json +17 -0
  462. core/skills/observability/SKILL.md +289 -0
  463. core/skills/observability/assets/observability-checklist.md +27 -0
  464. core/skills/observability/references/instrumentation.md +57 -0
  465. core/skills/observability/scripts/lint_logging.py +77 -0
  466. core/skills/payments/SKILL.md +102 -0
  467. core/skills/performance/SKILL.md +305 -0
  468. core/skills/performance/assets/perf-checklist.md +143 -0
  469. core/skills/performance/references/mobile-performance.md +249 -0
  470. core/skills/performance/references/web-vitals.md +209 -0
  471. core/skills/php/SKILL.md +116 -0
  472. core/skills/php/assets/php-checklist.md +26 -0
  473. core/skills/php/references/modern-php.md +64 -0
  474. core/skills/php/references/security.md +72 -0
  475. core/skills/php/scripts/scan_php_smells.py +99 -0
  476. core/skills/php/versions.json +9 -0
  477. core/skills/pr-mode-driver/SKILL.md +65 -0
  478. core/skills/realtime-websockets/SKILL.md +152 -0
  479. core/skills/redis/SKILL.md +105 -0
  480. core/skills/redis/assets/redis-checklist.md +27 -0
  481. core/skills/redis/references/operations.md +66 -0
  482. core/skills/redis/references/patterns.md +62 -0
  483. core/skills/redis/scripts/analyze_info.py +101 -0
  484. core/skills/redis/versions.json +9 -0
  485. core/skills/search/SKILL.md +91 -0
  486. core/skills/search/references/grep.md +76 -0
  487. core/skills/search/scripts/verify-count.sh +73 -0
  488. core/skills/search-infra/SKILL.md +109 -0
  489. core/skills/security-mobile/SKILL.md +394 -0
  490. core/skills/security-mobile/assets/mobile-security-checklist.md +117 -0
  491. core/skills/security-mobile/references/masvs-l1-checklist.md +127 -0
  492. core/skills/security-web/SKILL.md +217 -0
  493. core/skills/security-web/assets/security-web-checklist.md +167 -0
  494. core/skills/security-web/references/owasp-top-10.md +551 -0
  495. core/skills/security-web/references/supply-chain.md +179 -0
  496. core/skills/security-web/scripts/csp-check.sh +153 -0
  497. core/skills/shell-scripting/SKILL.md +128 -0
  498. core/skills/shell-scripting/assets/script-checklist.md +33 -0
  499. core/skills/shell-scripting/references/argument-parsing.md +84 -0
  500. core/skills/shell-scripting/references/bash-robustness.md +78 -0
  501. core/skills/shell-scripting/scripts/lint_script.sh +53 -0
  502. core/skills/shell-scripting/scripts/new_script.py +131 -0
  503. core/skills/shell-scripting/versions.json +23 -0
  504. core/skills/sql-authoring/SKILL.md +104 -0
  505. core/skills/sql-authoring/assets/query-review-checklist.md +26 -0
  506. core/skills/sql-authoring/references/query-patterns.md +89 -0
  507. core/skills/sql-authoring/references/reading-explain.md +52 -0
  508. core/skills/sql-authoring/scripts/analyze_plan.py +100 -0
  509. core/skills/sql-authoring/versions.json +17 -0
  510. core/skills/state-management/SKILL.md +428 -0
  511. core/skills/state-management/references/tanstack-query-recipes.md +301 -0
  512. core/skills/state-management/references/zustand-recipes.md +364 -0
  513. core/skills/supabase/SKILL.md +108 -0
  514. core/skills/supabase/assets/supabase-checklist.md +26 -0
  515. core/skills/supabase/references/realtime-and-storage.md +55 -0
  516. core/skills/supabase/references/rls-and-auth.md +65 -0
  517. core/skills/supabase/scripts/check_rls.py +88 -0
  518. core/skills/supabase/versions.json +9 -0
  519. core/skills/task-driver/SKILL.md +272 -0
  520. core/skills/task-driver/scripts/task-lint.sh +163 -0
  521. core/skills/technical-writing/SKILL.md +85 -0
  522. core/skills/technical-writing/assets/doc-checklist.md +29 -0
  523. core/skills/technical-writing/references/doc-anatomy.md +53 -0
  524. core/skills/technical-writing/references/writing-craft.md +59 -0
  525. core/skills/technical-writing/scripts/new_doc.py +81 -0
  526. core/skills/terraform-k8s/SKILL.md +133 -0
  527. core/skills/testing-strategy/SKILL.md +266 -0
  528. core/skills/testing-strategy/assets/test-review-checklist.md +25 -0
  529. core/skills/testing-strategy/references/test-types.md +52 -0
  530. core/skills/testing-strategy/scripts/coverage_gate.py +79 -0
  531. core/skills/thinking_os/SKILL.md +288 -0
  532. core/skills/thinking_os/scripts/classify.sh +122 -0
  533. core/skills/typescript/SKILL.md +110 -0
  534. core/skills/typescript/assets/typescript-checklist.md +24 -0
  535. core/skills/typescript/references/strictness.md +53 -0
  536. core/skills/typescript/references/type-system.md +86 -0
  537. core/skills/typescript/scripts/check_tsconfig.py +92 -0
  538. core/skills/typescript/versions.json +9 -0
  539. core/subsystems.yaml +202 -0
  540. core/thinking_os/__init__.py +1 -0
  541. core/thinking_os/_agent_markers.py +32 -0
  542. core/thinking_os/agents/README.md +71 -0
  543. core/thinking_os/agents/analyst.md +139 -0
  544. core/thinking_os/agents/architect.md +127 -0
  545. core/thinking_os/agents/debugger.md +111 -0
  546. core/thinking_os/agents/deployer.md +112 -0
  547. core/thinking_os/agents/distiller.md +28 -0
  548. core/thinking_os/agents/documenter.md +101 -0
  549. core/thinking_os/agents/implementer.md +135 -0
  550. core/thinking_os/agents/internal/session_observer.md +39 -0
  551. core/thinking_os/agents/observer.md +100 -0
  552. core/thinking_os/agents/onboarder.md +81 -0
  553. core/thinking_os/agents/refactorer.md +123 -0
  554. core/thinking_os/agents/repairer.md +47 -0
  555. core/thinking_os/agents/researcher.md +129 -0
  556. core/thinking_os/agents/reviewer.md +147 -0
  557. core/thinking_os/agents/security_auditor.md +133 -0
  558. core/thinking_os/background.py +405 -0
  559. core/thinking_os/bootstrap_outcomes.py +200 -0
  560. core/thinking_os/budget.py +302 -0
  561. core/thinking_os/capture.py +495 -0
  562. core/thinking_os/cognition.py +516 -0
  563. core/thinking_os/cognition_schemas.py +517 -0
  564. core/thinking_os/compress.py +192 -0
  565. core/thinking_os/concepts.py +233 -0
  566. core/thinking_os/dashboard.py +159 -0
  567. core/thinking_os/database.py +2883 -0
  568. core/thinking_os/decay.py +393 -0
  569. core/thinking_os/digest.py +295 -0
  570. core/thinking_os/dispatcher.py +192 -0
  571. core/thinking_os/dispatcher_helpers.py +48 -0
  572. core/thinking_os/dispatchers/__init__.py +3 -0
  573. core/thinking_os/dispatchers/default.py +47 -0
  574. core/thinking_os/distill.py +192 -0
  575. core/thinking_os/doc_indexer.py +905 -0
  576. core/thinking_os/embeddings.py +943 -0
  577. core/thinking_os/formula_composer.py +556 -0
  578. core/thinking_os/gate_marker.py +75 -0
  579. core/thinking_os/graph.py +296 -0
  580. core/thinking_os/graph_indexer.py +360 -0
  581. core/thinking_os/health_check.py +517 -0
  582. core/thinking_os/impact.py +119 -0
  583. core/thinking_os/memory_gc.py +356 -0
  584. core/thinking_os/migrator_embeddings.py +318 -0
  585. core/thinking_os/precision.py +194 -0
  586. core/thinking_os/presets/registry.yaml +127 -0
  587. core/thinking_os/record_outcome.py +399 -0
  588. core/thinking_os/repair.py +105 -0
  589. core/thinking_os/retrieval_quality.py +239 -0
  590. core/thinking_os/roles/analyst.yaml +83 -0
  591. core/thinking_os/roles/architect.yaml +88 -0
  592. core/thinking_os/roles/debugger.yaml +71 -0
  593. core/thinking_os/roles/deployer.yaml +71 -0
  594. core/thinking_os/roles/documenter.yaml +72 -0
  595. core/thinking_os/roles/implementer.yaml +83 -0
  596. core/thinking_os/roles/observer.yaml +70 -0
  597. core/thinking_os/roles/refactorer.yaml +71 -0
  598. core/thinking_os/roles/researcher.yaml +72 -0
  599. core/thinking_os/roles/reviewer.yaml +79 -0
  600. core/thinking_os/roles/security_auditor.yaml +83 -0
  601. core/thinking_os/roles_state.py +168 -0
  602. core/thinking_os/sanitizer.py +320 -0
  603. core/thinking_os/server.py +3160 -0
  604. core/thinking_os/session_enrich.py +272 -0
  605. core/thinking_os/session_observe_worker.py +111 -0
  606. core/thinking_os/session_startup.py +74 -0
  607. core/thinking_os/session_summary.py +245 -0
  608. core/thinking_os/situations/registry.yaml +99 -0
  609. core/thinking_os/task_analyzer.py +462 -0
  610. core/thinking_os/task_parser.py +342 -0
  611. core/thinking_os/task_sync.py +73 -0
  612. core/thinking_os/tools/__init__.py +6 -0
  613. core/thinking_os/tools/_shared.py +947 -0
  614. core/thinking_os/tools/cognition.py +1867 -0
  615. core/thinking_os/tools/docs.py +770 -0
  616. core/thinking_os/tools/learning.py +2078 -0
  617. core/thinking_os/tools/logs.py +79 -0
  618. core/thinking_os/tools/memory.py +840 -0
  619. core/thinking_os/tools/metrics.py +200 -0
  620. core/thinking_os/tools/retrieve.py +415 -0
  621. core/thinking_os/tools/routing.py +658 -0
  622. core/thinking_os/tools/tasks.py +449 -0
  623. core/thinking_os/tools/trajectory.py +181 -0
  624. core/thinking_os/tracing.py +235 -0
  625. core/web/__init__.py +5 -0
  626. core/web/_cache.py +118 -0
  627. core/web/_deps.py +56 -0
  628. core/web/_envelope.py +85 -0
  629. core/web/_project_context.py +140 -0
  630. core/web/chat_providers.py +108 -0
  631. core/web/init_jobs.py +216 -0
  632. core/web/routes/__init__.py +25 -0
  633. core/web/routes/_bounded_read.py +76 -0
  634. core/web/routes/board.py +1089 -0
  635. core/web/routes/cognition.py +1838 -0
  636. core/web/routes/config.py +635 -0
  637. core/web/routes/graph.py +513 -0
  638. core/web/routes/health.py +180 -0
  639. core/web/routes/hooks.py +288 -0
  640. core/web/routes/hub.py +1219 -0
  641. core/web/routes/logs.py +374 -0
  642. core/web/routes/metrics.py +43 -0
  643. core/web/routes/observability.py +400 -0
  644. core/web/routes/patterns.py +227 -0
  645. core/web/routes/presence.py +609 -0
  646. core/web/routes/roles.py +446 -0
  647. core/web/routes/scheduled.py +261 -0
  648. core/web/routes/search.py +238 -0
  649. core/web/routes/sessions.py +220 -0
  650. core/web/routes/settings.py +363 -0
  651. core/web/routes/stream.py +547 -0
  652. core/web/security.py +157 -0
  653. core/web/server.py +283 -0
  654. graph_os/__init__.py +29 -0
  655. graph_os/backend.py +233 -0
  656. graph_os/backends/__init__.py +13 -0
  657. graph_os/backends/sqlite_backend.py +1053 -0
  658. graph_os/communities.py +410 -0
  659. graph_os/enterprise.py +218 -0
  660. graph_os/entry_points.py +226 -0
  661. graph_os/extractors/__init__.py +25 -0
  662. graph_os/extractors/code_generic.py +914 -0
  663. graph_os/extractors/code_go.py +1422 -0
  664. graph_os/extractors/code_json.py +340 -0
  665. graph_os/extractors/code_php.py +979 -0
  666. graph_os/extractors/code_python.py +1454 -0
  667. graph_os/extractors/code_shell.py +538 -0
  668. graph_os/extractors/code_toml.py +302 -0
  669. graph_os/extractors/code_ts.py +1665 -0
  670. graph_os/extractors/code_yaml.py +394 -0
  671. graph_os/extractors/contracts.py +1592 -0
  672. graph_os/extractors/md_links.py +890 -0
  673. graph_os/extractors/task_deps.py +345 -0
  674. graph_os/groups/__init__.py +22 -0
  675. graph_os/groups/cross_repo.py +156 -0
  676. graph_os/groups/manifest.py +141 -0
  677. graph_os/ingest/__init__.py +19 -0
  678. graph_os/ingest/base.py +306 -0
  679. graph_os/ingest/github.py +112 -0
  680. graph_os/ingest/zip.py +95 -0
  681. graph_os/toolchain.py +393 -0
  682. graph_os/tools/__init__.py +9 -0
  683. graph_os/tools/graph.py +5573 -0
  684. graph_os/tools/reindex_dispatch.py +730 -0
  685. graph_os/tree_sitter_overlay.py +235 -0
  686. graph_os/types.py +252 -0
  687. graph_os/vec_index.py +277 -0
  688. graph_os/viewer/__init__.py +12 -0
  689. graph_os/viewer/exporter.py +93 -0
  690. graph_os/viewer/template.py +189 -0
  691. scheduled/__init__.py +0 -0
  692. scheduled/_activity.py +126 -0
  693. scheduled/_state.py +113 -0
  694. scheduled/config.py +86 -0
  695. scheduled/dep_reconcile.py +135 -0
  696. scheduled/error_sweep.py +137 -0
  697. scheduled/nightly.py +930 -0
  698. scheduled/responsive_extract.py +65 -0
  699. scripts/__init__.py +4 -0
  700. scripts/_commit_msg_body.sh +31 -0
  701. scripts/_post_commit_body.sh +49 -0
  702. scripts/_pre_commit_body.sh +121 -0
  703. scripts/_prepare_commit_msg_body.sh +53 -0
  704. scripts/audit_mcp_tools.py +693 -0
  705. scripts/bench_sdk_dispatcher.py +177 -0
  706. scripts/capture_golden.py +169 -0
  707. scripts/check_graph_phantoms.py +75 -0
  708. scripts/dev/audit_doc_links.py +359 -0
  709. scripts/dev/audit_scaffold_module_tags.py +107 -0
  710. scripts/dev/backfill_doc_headers.py +328 -0
  711. scripts/dev/backfill_nav_lines.py +119 -0
  712. scripts/dev/fix_nav_placement.py +106 -0
  713. scripts/dev/inspect_sdk_options.py +45 -0
  714. scripts/dev/migrate_check_ids.py +170 -0
  715. scripts/dev/strip_purpose_blocks.py +154 -0
  716. scripts/dump_openapi.py +66 -0
  717. scripts/e2e_dispatch_tool.py +195 -0
  718. scripts/generate_manifest.py +166 -0
  719. scripts/golden_sections.py +20 -0
  720. scripts/graph_demo.py +161 -0
  721. scripts/install-git-hooks.sh +47 -0
  722. scripts/migrate_embeddings_minilm_to_bge_m3.py +84 -0
  723. scripts/operational_eval.py +445 -0
  724. scripts/probe_agent_session_resolver.py +59 -0
  725. scripts/prune_deleted_path.py +127 -0
  726. scripts/refactor_agent_dual_mode.py +171 -0
  727. scripts/refresh_skill_versions.py +302 -0
  728. scripts/regen_doc_index.py +209 -0
  729. scripts/regen_doctor_schema.py +62 -0
  730. scripts/regen_rules.py +94 -0
  731. scripts/rename_formulas_to_semantic.py +241 -0
  732. scripts/smoke_db_connections.py +183 -0
  733. scripts/smoke_doc_header.py +72 -0
  734. scripts/smoke_graph_e2e.py +374 -0
  735. scripts/smoke_sdk_dispatch.py +84 -0
  736. scripts/smoke_uid_resolver.py +164 -0
  737. scripts/verify_dispatchers.py +244 -0
  738. scripts/verify_phase_c_e2e.py +436 -0
  739. templates/__init__.py +6 -0
  740. templates/_base/Makefile.base +353 -0
  741. templates/_base/base.yaml +59 -0
  742. templates/_base/coding-os.yaml.template +39 -0
  743. templates/_base/dimension-registry.template.md +68 -0
  744. templates/_base/domain-config.template.json +46 -0
  745. templates/_base/fragments/anatomy-map.md.tmpl +11 -0
  746. templates/_base/fragments/context-discipline.md.tmpl +3 -0
  747. templates/_base/fragments/core-loop.md.tmpl +52 -0
  748. templates/_base/fragments/engineering-routing.md.tmpl +3 -0
  749. templates/_base/fragments/header.md.tmpl +6 -0
  750. templates/_base/fragments/identity.md.tmpl +3 -0
  751. templates/_base/fragments/principles.md.tmpl +3 -0
  752. templates/_base/fragments/retrieval-routing.md.tmpl +23 -0
  753. templates/_base/fragments/session-handoff.md.tmpl +3 -0
  754. templates/_base/fragments/skills.md.tmpl +3 -0
  755. templates/_base/fragments/ssot-map.md.tmpl +3 -0
  756. templates/_base/fragments/stop-conditions.md.tmpl +3 -0
  757. templates/_base/fragments/subagent-dispatch.md.tmpl +3 -0
  758. templates/_base/fragments/task-authoring.md.tmpl +69 -0
  759. templates/_base/fragments/task-logging.md.tmpl +8 -0
  760. templates/_base/fragments/tool-routing.md.tmpl +9 -0
  761. templates/_base/fragments/verification-matrix.md.tmpl +12 -0
  762. templates/_base/lang/dart/analysis_options.yaml +7 -0
  763. templates/_base/lang/php/phpcs.xml.dist +10 -0
  764. templates/_base/lang/python/pyproject.toml +19 -0
  765. templates/_base/lang/rust/clippy.toml +5 -0
  766. templates/_base/lang/rust/rustfmt.toml +3 -0
  767. templates/_base/lang/typescript/eslint.config.js +26 -0
  768. templates/_base/lang/typescript/tsconfig.json +15 -0
  769. templates/_base/lang/typescript/vitest.config.ts +10 -0
  770. templates/_base/scaffold/changes.log +1 -0
  771. templates/_base/scaffold/docs/00-index.md +55 -0
  772. templates/_base/scaffold/docs/_meta/feature-dependency-tree.md +30 -0
  773. templates/_base/scaffold/docs/_meta/foundation-map.md +56 -0
  774. templates/_base/scaffold/docs/_meta/questions.md +8 -0
  775. templates/_base/scaffold/docs/_meta/roadmap.md +33 -0
  776. templates/_base/scaffold/docs/api-contracts/00-index.md +58 -0
  777. templates/_base/scaffold/docs/api-contracts/error-format.md +58 -0
  778. templates/_base/scaffold/docs/architecture/00-index.md +42 -0
  779. templates/_base/scaffold/docs/architecture/adr/00-index.md +39 -0
  780. templates/_base/scaffold/docs/engineering/00-index.md +9 -0
  781. templates/_base/scaffold/docs/governance/00-index.md +55 -0
  782. templates/_base/scaffold/docs/governance/_templates/doc-cheat-sheet.md +202 -0
  783. templates/_base/scaffold/docs/governance/_templates/playbook-template.md +81 -0
  784. templates/_base/scaffold/docs/governance/_templates/post-mortem-template.md +85 -0
  785. templates/_base/scaffold/docs/governance/_templates/runbook-template.md +88 -0
  786. templates/_base/scaffold/docs/governance/_templates/security-review-template.md +111 -0
  787. templates/_base/scaffold/docs/governance/_templates/task-detail.md +61 -0
  788. templates/_base/scaffold/docs/governance/agent-workflow.md +101 -0
  789. templates/_base/scaffold/docs/governance/anatomy-contract.md +150 -0
  790. templates/_base/scaffold/docs/governance/critical-rules.md +224 -0
  791. templates/_base/scaffold/docs/governance/decision-records.md +58 -0
  792. templates/_base/scaffold/docs/governance/docs-first-protocol.md +157 -0
  793. templates/_base/scaffold/docs/governance/docs-system.md +151 -0
  794. templates/_base/scaffold/docs/governance/gdpr-compliance.md +66 -0
  795. templates/_base/scaffold/docs/governance/mcp-tool-inventory.md +112 -0
  796. templates/_base/scaffold/docs/governance/risk-register.md +26 -0
  797. templates/_base/scaffold/docs/governance/scaffold-boundary-contract.md +161 -0
  798. templates/_base/scaffold/docs/governance/task-lifecycle.md +125 -0
  799. templates/_base/scaffold/docs/governance/wrapper-derivation.md +50 -0
  800. templates/_base/scaffold/docs/insights/00-index.md +17 -0
  801. templates/_base/scaffold/docs/ops/00-index.md +59 -0
  802. templates/_base/scaffold/docs/ops/runbooks/00-index.md +9 -0
  803. templates/_base/scaffold/docs/playbooks/00-index.md +12 -0
  804. templates/_base/scaffold/docs/playbooks/research-validation.md +29 -0
  805. templates/_base/scaffold/docs/playbooks/security-review.md +41 -0
  806. templates/_base/scaffold/docs/prd/00-index.md +43 -0
  807. templates/_base/scaffold/docs/prd/01-snapshot-vision.md +56 -0
  808. templates/_base/scaffold/docs/workflow/workflow-guide.md +138 -0
  809. templates/_base/scaffold/src/shared/README.md +23 -0
  810. templates/_base/skill-enforcement.template.md +14 -0
  811. templates/_base/task-detail.template.md +61 -0
  812. templates/_presets/ai-saas.yaml +9 -0
  813. templates/_presets/django-next.yaml +8 -0
  814. templates/_presets/dotnet-react.yaml +8 -0
  815. templates/_presets/flutter-baas.yaml +8 -0
  816. templates/_presets/go-react.yaml +8 -0
  817. templates/_presets/hexagonal-product.yaml +16 -0
  818. templates/_presets/jamstack.yaml +8 -0
  819. templates/_presets/laravel-vue.yaml +8 -0
  820. templates/_presets/mean.yaml +8 -0
  821. templates/_presets/mern.yaml +9 -0
  822. templates/_presets/nest-angular.yaml +8 -0
  823. templates/_presets/nextjs-fastapi.yaml +10 -0
  824. templates/_presets/nuxt-fullstack.yaml +9 -0
  825. templates/_presets/pern.yaml +9 -0
  826. templates/_presets/rails-react.yaml +8 -0
  827. templates/_presets/rn-api.yaml +8 -0
  828. templates/_presets/rust-svelte.yaml +8 -0
  829. templates/_presets/spring-react.yaml +8 -0
  830. templates/_presets/t3-style.yaml +9 -0
  831. templates/_presets/tall.yaml +8 -0
  832. templates/_presets/wordpress-cms.yaml +8 -0
  833. templates/angular/rules/frontend.md +19 -0
  834. templates/angular/scaffold/docs/engineering/accessibility.md +46 -0
  835. templates/angular/scaffold/docs/engineering/angular-rules.md +35 -0
  836. templates/angular/scaffold/docs/playbooks/angular-app.md +42 -0
  837. templates/angular/scaffold/src/frontend/angular.json +52 -0
  838. templates/angular/scaffold/src/frontend/package.json +28 -0
  839. templates/angular/scaffold/src/frontend/src/app/app.component.ts +19 -0
  840. templates/angular/scaffold/src/frontend/src/app/app.config.ts +22 -0
  841. templates/angular/scaffold/src/frontend/src/app/app.routes.ts +8 -0
  842. templates/angular/scaffold/src/frontend/src/app/core/global-error-handler.ts +12 -0
  843. templates/angular/scaffold/src/frontend/src/app/health/health.component.ts +14 -0
  844. templates/angular/scaffold/src/frontend/src/app/health/health.service.spec.ts +16 -0
  845. templates/angular/scaffold/src/frontend/src/app/health/health.service.ts +10 -0
  846. templates/angular/scaffold/src/frontend/src/index.html +11 -0
  847. templates/angular/scaffold/src/frontend/src/main.ts +9 -0
  848. templates/angular/scaffold/src/frontend/src/styles.css +14 -0
  849. templates/angular/scaffold/src/frontend/tsconfig.app.json +8 -0
  850. templates/angular/scaffold/src/frontend/tsconfig.json +27 -0
  851. templates/angular/scaffold/src/frontend/tsconfig.spec.json +8 -0
  852. templates/angular/scaffold-boundary.yaml +27 -0
  853. templates/angular/skills/angular/SKILL.md +79 -0
  854. templates/angular/skills/angular/references/anatomy.md +69 -0
  855. templates/angular/stack.yaml +75 -0
  856. templates/aspnet-core/rules/backend.md +20 -0
  857. templates/aspnet-core/scaffold/docs/engineering/aspnet-core-rules.md +36 -0
  858. templates/aspnet-core/scaffold/docs/playbooks/aspnet-core-service.md +40 -0
  859. templates/aspnet-core/scaffold/src/backend/Backend.csproj +11 -0
  860. templates/aspnet-core/scaffold/src/backend/Backend.sln +27 -0
  861. templates/aspnet-core/scaffold/src/backend/Common/ExceptionHandlingMiddleware.cs +35 -0
  862. templates/aspnet-core/scaffold/src/backend/Features/Health/HealthEndpoints.cs +10 -0
  863. templates/aspnet-core/scaffold/src/backend/Features/Health/HealthService.cs +9 -0
  864. templates/aspnet-core/scaffold/src/backend/Program.cs +22 -0
  865. templates/aspnet-core/scaffold/src/backend/tests/Backend.Tests/Backend.Tests.csproj +22 -0
  866. templates/aspnet-core/scaffold/src/backend/tests/Backend.Tests/HealthServiceTests.cs +15 -0
  867. templates/aspnet-core/scaffold-boundary.yaml +24 -0
  868. templates/aspnet-core/skills/aspnet-core/SKILL.md +80 -0
  869. templates/aspnet-core/skills/aspnet-core/references/anatomy.md +65 -0
  870. templates/aspnet-core/stack.yaml +68 -0
  871. templates/astro/rules/frontend.md +20 -0
  872. templates/astro/scaffold/docs/engineering/astro-rules.md +40 -0
  873. templates/astro/scaffold/docs/playbooks/astro-app.md +57 -0
  874. templates/astro/scaffold/docs/playbooks/content-seo.md +37 -0
  875. templates/astro/scaffold/src/frontend/astro.config.mjs +10 -0
  876. templates/astro/scaffold/src/frontend/package.json +23 -0
  877. templates/astro/scaffold/src/frontend/src/components/Greeting.astro +13 -0
  878. templates/astro/scaffold/src/frontend/src/content/posts/hello.md +10 -0
  879. templates/astro/scaffold/src/frontend/src/content.config.ts +19 -0
  880. templates/astro/scaffold/src/frontend/src/lib/problem.test.ts +39 -0
  881. templates/astro/scaffold/src/frontend/src/lib/problem.ts +30 -0
  882. templates/astro/scaffold/src/frontend/src/pages/api/health.ts +13 -0
  883. templates/astro/scaffold/src/frontend/src/pages/index.astro +21 -0
  884. templates/astro/scaffold/src/frontend/tsconfig.json +9 -0
  885. templates/astro/scaffold/src/frontend/vitest.config.ts +10 -0
  886. templates/astro/scaffold-boundary.yaml +28 -0
  887. templates/astro/skills/astro/SKILL.md +71 -0
  888. templates/astro/skills/astro/references/anatomy.md +66 -0
  889. templates/astro/stack.yaml +75 -0
  890. templates/csharp-plain/scaffold/src/backend/Backend.csproj +12 -0
  891. templates/csharp-plain/scaffold/src/backend/Program.cs +1 -0
  892. templates/csharp-plain/scaffold-boundary.yaml +23 -0
  893. templates/csharp-plain/stack.yaml +50 -0
  894. templates/django/rules/backend.md +18 -0
  895. templates/django/scaffold/docs/engineering/anti-ambiguity.md +74 -0
  896. templates/django/scaffold/docs/engineering/backend-rules.md +133 -0
  897. templates/django/scaffold/docs/engineering/glossary.md +51 -0
  898. templates/django/scaffold/docs/engineering/logging-standards.md +107 -0
  899. templates/django/scaffold/docs/engineering/naming-conventions.md +68 -0
  900. templates/django/scaffold/docs/engineering/secrets-rotation-runbook.md +142 -0
  901. templates/django/scaffold/docs/playbooks/backend-api.md +119 -0
  902. templates/django/scaffold/src/backend/config/__init__.py +0 -0
  903. templates/django/scaffold/src/backend/config/settings.py +31 -0
  904. templates/django/scaffold/src/backend/config/urls.py +11 -0
  905. templates/django/scaffold/src/backend/config/wsgi.py +6 -0
  906. templates/django/scaffold/src/backend/manage.py +14 -0
  907. templates/django/scaffold/src/backend/pyproject.toml +37 -0
  908. templates/django/scaffold/src/backend/tests/test_health.py +4 -0
  909. templates/django/scaffold-boundary.yaml +25 -0
  910. templates/django/skills/python-django/SKILL.md +450 -0
  911. templates/django/skills/python-django/references/anatomy.md +117 -0
  912. templates/django/skills/python-django/scripts/new_endpoint.py +89 -0
  913. templates/django/stack.yaml +73 -0
  914. templates/fastapi/rules/backend.md +18 -0
  915. templates/fastapi/scaffold/docs/engineering/fastapi-rules.md +37 -0
  916. templates/fastapi/scaffold/docs/playbooks/fastapi-service.md +30 -0
  917. templates/fastapi/scaffold/src/backend/app/__init__.py +0 -0
  918. templates/fastapi/scaffold/src/backend/app/main.py +8 -0
  919. templates/fastapi/scaffold/src/backend/pyproject.toml +39 -0
  920. templates/fastapi/scaffold/src/backend/tests/test_health.py +11 -0
  921. templates/fastapi/scaffold-boundary.yaml +25 -0
  922. templates/fastapi/skills/python-fastapi/SKILL.md +75 -0
  923. templates/fastapi/skills/python-fastapi/references/anatomy.md +117 -0
  924. templates/fastapi/skills/python-fastapi/scripts/new_endpoint.py +101 -0
  925. templates/fastapi/stack.yaml +57 -0
  926. templates/flutter/rules/mobile.md +26 -0
  927. templates/flutter/scaffold/docs/engineering/flutter-rules.md +35 -0
  928. templates/flutter/scaffold/docs/playbooks/flutter-app.md +45 -0
  929. templates/flutter/scaffold/src/mobile/lib/core/error_mapper.dart +17 -0
  930. templates/flutter/scaffold/src/mobile/lib/core/router.dart +13 -0
  931. templates/flutter/scaffold/src/mobile/lib/main.dart +22 -0
  932. templates/flutter/scaffold/src/mobile/lib/screens/health_screen.dart +32 -0
  933. templates/flutter/scaffold/src/mobile/lib/services/health_service.dart +11 -0
  934. templates/flutter/scaffold/src/mobile/lib/state/health_provider.dart +13 -0
  935. templates/flutter/scaffold/src/mobile/pubspec.yaml +22 -0
  936. templates/flutter/scaffold/src/mobile/test/health_provider_test.dart +90 -0
  937. templates/flutter/scaffold-boundary.yaml +28 -0
  938. templates/flutter/skills/flutter/SKILL.md +76 -0
  939. templates/flutter/skills/flutter/references/anatomy.md +64 -0
  940. templates/flutter/stack.yaml +70 -0
  941. templates/go/rules/backend.md +19 -0
  942. templates/go/scaffold/docs/engineering/go-rules.md +45 -0
  943. templates/go/scaffold/docs/playbooks/go-service.md +30 -0
  944. templates/go/scaffold/src/backend/cmd/api/main.go +22 -0
  945. templates/go/scaffold/src/backend/cmd/api/main_test.go +20 -0
  946. templates/go/scaffold/src/backend/go.mod +3 -0
  947. templates/go/scaffold-boundary.yaml +25 -0
  948. templates/go/skills/go-patterns/SKILL.md +68 -0
  949. templates/go/skills/go-patterns/assets/go-checklist.md +29 -0
  950. templates/go/skills/go-patterns/references/anatomy.md +115 -0
  951. templates/go/skills/go-patterns/references/go-2026-idioms.md +92 -0
  952. templates/go/skills/go-patterns/scripts/new_endpoint.py +117 -0
  953. templates/go/skills/go-patterns/versions.json +16 -0
  954. templates/go/stack.yaml +54 -0
  955. templates/go-fiber/rules/backend.md +20 -0
  956. templates/go-fiber/scaffold/docs/engineering/fiber-rules.md +97 -0
  957. templates/go-fiber/scaffold/docs/playbooks/fiber-service.md +149 -0
  958. templates/go-fiber/scaffold/src/backend/cmd/api/main.go +21 -0
  959. templates/go-fiber/scaffold/src/backend/cmd/api/main_test.go +17 -0
  960. templates/go-fiber/scaffold/src/backend/go.mod +23 -0
  961. templates/go-fiber/scaffold/src/backend/go.sum +49 -0
  962. templates/go-fiber/scaffold-boundary.yaml +26 -0
  963. templates/go-fiber/skills/go-fiber/SKILL.md +203 -0
  964. templates/go-fiber/skills/go-fiber/assets/fiber-checklist.md +26 -0
  965. templates/go-fiber/skills/go-fiber/references/anatomy.md +116 -0
  966. templates/go-fiber/skills/go-fiber/references/fiber-v3-patterns.md +89 -0
  967. templates/go-fiber/skills/go-fiber/scripts/new_endpoint.py +107 -0
  968. templates/go-fiber/skills/go-fiber/versions.json +16 -0
  969. templates/go-fiber/stack.yaml +59 -0
  970. templates/go-plain/scaffold/src/backend/go.mod +3 -0
  971. templates/go-plain/scaffold/src/backend/main.go +7 -0
  972. templates/go-plain/scaffold-boundary.yaml +22 -0
  973. templates/go-plain/stack.yaml +51 -0
  974. templates/java-plain/scaffold/src/backend/mvnw +302 -0
  975. templates/java-plain/scaffold/src/backend/pom.xml +41 -0
  976. templates/java-plain/scaffold/src/backend/src/main/java/com/example/app/Main.java +10 -0
  977. templates/java-plain/scaffold-boundary.yaml +23 -0
  978. templates/java-plain/stack.yaml +51 -0
  979. templates/laravel/rules/backend.md +20 -0
  980. templates/laravel/scaffold/docs/engineering/laravel-rules.md +28 -0
  981. templates/laravel/scaffold/docs/playbooks/laravel-service.md +28 -0
  982. templates/laravel/scaffold/src/backend/app/Exceptions/Handler.php +27 -0
  983. templates/laravel/scaffold/src/backend/app/Http/Controllers/HealthController.php +15 -0
  984. templates/laravel/scaffold/src/backend/app/Support/HealthStatus.php +14 -0
  985. templates/laravel/scaffold/src/backend/composer.json +24 -0
  986. templates/laravel/scaffold/src/backend/phpunit.xml +10 -0
  987. templates/laravel/scaffold/src/backend/public/index.php +8 -0
  988. templates/laravel/scaffold/src/backend/routes/api.php +7 -0
  989. templates/laravel/scaffold/src/backend/tests/Unit/HealthStatusTest.php +16 -0
  990. templates/laravel/scaffold-boundary.yaml +23 -0
  991. templates/laravel/skills/laravel/SKILL.md +56 -0
  992. templates/laravel/skills/laravel/references/anatomy.md +65 -0
  993. templates/laravel/stack.yaml +66 -0
  994. templates/meta/rules/graph-first.md +27 -0
  995. templates/meta/rules/hook-author.md +19 -0
  996. templates/meta/rules/mcp-tool-author.md +18 -0
  997. templates/meta/rules/meta-engineering.md +17 -0
  998. templates/meta/scaffold-boundary.yaml +55 -0
  999. templates/meta/skills/claude-sdk-integration/SKILL.md +163 -0
  1000. templates/meta/skills/claude-sdk-integration/assets/sdk-checklist.md +27 -0
  1001. templates/meta/skills/claude-sdk-integration/scripts/check_model_ids.py +97 -0
  1002. templates/meta/skills/graph-os-authoring/SKILL.md +278 -0
  1003. templates/meta/skills/graph-os-authoring/assets/graph-os-checklist.md +25 -0
  1004. templates/meta/skills/graph-os-authoring/scripts/new_extractor.py +76 -0
  1005. templates/meta/skills/hook-authoring/SKILL.md +292 -0
  1006. templates/meta/skills/hook-authoring/assets/hook-checklist.md +30 -0
  1007. templates/meta/skills/hook-authoring/scripts/new_hook.sh +75 -0
  1008. templates/meta/skills/mcp-tool-authoring/SKILL.md +301 -0
  1009. templates/meta/skills/mcp-tool-authoring/assets/mcp-tool-checklist.md +29 -0
  1010. templates/meta/skills/mcp-tool-authoring/scripts/new_tool.py +74 -0
  1011. templates/meta/skills/meta-engineering/SKILL.md +151 -0
  1012. templates/meta/skills/meta-engineering/assets/meta-edit-checklist.md +28 -0
  1013. templates/meta/skills/meta-engineering/scripts/which_layer.py +61 -0
  1014. templates/meta/skills/python-meta-server/SKILL.md +162 -0
  1015. templates/meta/skills/python-meta-server/assets/meta-server-checklist.md +28 -0
  1016. templates/meta/skills/python-meta-server/scripts/check_envelope.py +91 -0
  1017. templates/meta/skills/react-vite-hub/SKILL.md +140 -0
  1018. templates/meta/skills/react-vite-hub/assets/hub-ui-checklist.md +23 -0
  1019. templates/meta/skills/react-vite-hub/scripts/check_vite_env.py +73 -0
  1020. templates/meta/stack.yaml +119 -0
  1021. templates/nestjs/rules/backend.md +20 -0
  1022. templates/nestjs/scaffold/docs/engineering/nestjs-rules.md +33 -0
  1023. templates/nestjs/scaffold/docs/playbooks/nestjs-service.md +39 -0
  1024. templates/nestjs/scaffold/src/backend/nest-cli.json +5 -0
  1025. templates/nestjs/scaffold/src/backend/package.json +28 -0
  1026. templates/nestjs/scaffold/src/backend/src/app.module.ts +9 -0
  1027. templates/nestjs/scaffold/src/backend/src/common/all-exceptions.filter.ts +59 -0
  1028. templates/nestjs/scaffold/src/backend/src/health/health.controller.ts +14 -0
  1029. templates/nestjs/scaffold/src/backend/src/health/health.module.ts +10 -0
  1030. templates/nestjs/scaffold/src/backend/src/health/health.service.spec.ts +21 -0
  1031. templates/nestjs/scaffold/src/backend/src/health/health.service.ts +9 -0
  1032. templates/nestjs/scaffold/src/backend/src/main.ts +26 -0
  1033. templates/nestjs/scaffold/src/backend/tsconfig.json +16 -0
  1034. templates/nestjs/scaffold/src/backend/vitest.config.ts +9 -0
  1035. templates/nestjs/scaffold-boundary.yaml +25 -0
  1036. templates/nestjs/skills/nestjs/SKILL.md +67 -0
  1037. templates/nestjs/skills/nestjs/references/anatomy.md +65 -0
  1038. templates/nestjs/stack.yaml +68 -0
  1039. templates/nextjs/rules/frontend.md +18 -0
  1040. templates/nextjs/scaffold/docs/design/00-index.md +23 -0
  1041. templates/nextjs/scaffold/docs/design/colors-tokens.md +141 -0
  1042. templates/nextjs/scaffold/docs/design/components-patterns.md +159 -0
  1043. templates/nextjs/scaffold/docs/design/motion-accessibility.md +137 -0
  1044. templates/nextjs/scaffold/docs/design/typography-spacing.md +107 -0
  1045. templates/nextjs/scaffold/docs/engineering/accessibility-web.md +56 -0
  1046. templates/nextjs/scaffold/docs/engineering/copywriting-standard.md +102 -0
  1047. templates/nextjs/scaffold/docs/engineering/formatting-rules.md +89 -0
  1048. templates/nextjs/scaffold/docs/engineering/frontend-rendering-rules.md +80 -0
  1049. templates/nextjs/scaffold/docs/engineering/frontend-rules.md +183 -0
  1050. templates/nextjs/scaffold/docs/engineering/i18n-policy.md +99 -0
  1051. templates/nextjs/scaffold/docs/pages-content-spec/00-index.md +78 -0
  1052. templates/nextjs/scaffold/docs/playbooks/content-seo.md +55 -0
  1053. templates/nextjs/scaffold/docs/playbooks/docs-governance.md +51 -0
  1054. templates/nextjs/scaffold/docs/playbooks/frontend-ui.md +63 -0
  1055. templates/nextjs/scaffold/src/frontend/app/layout.tsx +14 -0
  1056. templates/nextjs/scaffold/src/frontend/app/page.tsx +3 -0
  1057. templates/nextjs/scaffold/src/frontend/eslint.config.js +17 -0
  1058. templates/nextjs/scaffold/src/frontend/lib/greeting.test.ts +9 -0
  1059. templates/nextjs/scaffold/src/frontend/lib/greeting.ts +3 -0
  1060. templates/nextjs/scaffold/src/frontend/package.json +28 -0
  1061. templates/nextjs/scaffold/src/frontend/tsconfig.json +18 -0
  1062. templates/nextjs/scaffold/src/frontend/vitest.config.ts +10 -0
  1063. templates/nextjs/scaffold-boundary.yaml +30 -0
  1064. templates/nextjs/skills/nextjs-react/SKILL.md +485 -0
  1065. templates/nextjs/skills/nextjs-react/references/anatomy.md +116 -0
  1066. templates/nextjs/skills/nextjs-react/scripts/new_component.py +72 -0
  1067. templates/nextjs/stack.yaml +81 -0
  1068. templates/node-express/rules/backend.md +20 -0
  1069. templates/node-express/scaffold/docs/engineering/express-rules.md +30 -0
  1070. templates/node-express/scaffold/docs/playbooks/express-service.md +35 -0
  1071. templates/node-express/scaffold/src/backend/package.json +24 -0
  1072. templates/node-express/scaffold/src/backend/src/index.ts +17 -0
  1073. templates/node-express/scaffold/src/backend/src/middleware/error-handler.ts +12 -0
  1074. templates/node-express/scaffold/src/backend/src/routes/health.test.ts +33 -0
  1075. templates/node-express/scaffold/src/backend/src/routes/health.ts +7 -0
  1076. templates/node-express/scaffold/src/backend/tsconfig.json +15 -0
  1077. templates/node-express/scaffold/src/backend/types/express-bootstrap.d.ts +21 -0
  1078. templates/node-express/scaffold-boundary.yaml +25 -0
  1079. templates/node-express/skills/node-express/SKILL.md +70 -0
  1080. templates/node-express/skills/node-express/references/anatomy.md +63 -0
  1081. templates/node-express/stack.yaml +63 -0
  1082. templates/python/scaffold/docs/engineering/python-rules.md +27 -0
  1083. templates/python/scaffold/docs/playbooks/python-library.md +35 -0
  1084. templates/python/stack.yaml +60 -0
  1085. templates/rails/rules/backend.md +10 -0
  1086. templates/rails/scaffold/docs/engineering/rails-rules.md +33 -0
  1087. templates/rails/scaffold/docs/playbooks/rails-service.md +42 -0
  1088. templates/rails/scaffold/src/backend/Gemfile +12 -0
  1089. templates/rails/scaffold/src/backend/app/controllers/application_controller.rb +26 -0
  1090. templates/rails/scaffold/src/backend/app/controllers/health_controller.rb +6 -0
  1091. templates/rails/scaffold/src/backend/app/models/health.rb +6 -0
  1092. templates/rails/scaffold/src/backend/config/application.rb +12 -0
  1093. templates/rails/scaffold/src/backend/config/boot.rb +3 -0
  1094. templates/rails/scaffold/src/backend/config/routes.rb +4 -0
  1095. templates/rails/scaffold/src/backend/config.ru +5 -0
  1096. templates/rails/scaffold/src/backend/spec/rails_helper.rb +18 -0
  1097. templates/rails/scaffold/src/backend/spec/requests/health_spec.rb +24 -0
  1098. templates/rails/scaffold-boundary.yaml +25 -0
  1099. templates/rails/skills/rails/SKILL.md +62 -0
  1100. templates/rails/skills/rails/references/anatomy.md +71 -0
  1101. templates/rails/stack.yaml +72 -0
  1102. templates/react-native/rules/mobile.md +26 -0
  1103. templates/react-native/scaffold/docs/engineering/accessibility-mobile.md +95 -0
  1104. templates/react-native/scaffold/docs/engineering/mobile-rules.md +56 -0
  1105. templates/react-native/scaffold/docs/engineering/offline-first.md +61 -0
  1106. templates/react-native/scaffold/docs/playbooks/mobile-app.md +49 -0
  1107. templates/react-native/scaffold/src/mobile/App.tsx +9 -0
  1108. templates/react-native/scaffold/src/mobile/eslint.config.js +17 -0
  1109. templates/react-native/scaffold/src/mobile/package.json +23 -0
  1110. templates/react-native/scaffold/src/mobile/src/greeting.test.ts +9 -0
  1111. templates/react-native/scaffold/src/mobile/src/greeting.ts +3 -0
  1112. templates/react-native/scaffold/src/mobile/tsconfig.json +17 -0
  1113. templates/react-native/scaffold/src/mobile/vitest.config.ts +10 -0
  1114. templates/react-native/scaffold-boundary.yaml +30 -0
  1115. templates/react-native/skills/react-native-mobile/SKILL.md +119 -0
  1116. templates/react-native/skills/react-native-mobile/assets/rn-mobile-checklist.md +28 -0
  1117. templates/react-native/skills/react-native-mobile/references/anatomy.md +140 -0
  1118. templates/react-native/skills/react-native-mobile/references/rn-2026-practices.md +54 -0
  1119. templates/react-native/skills/react-native-mobile/scripts/new_screen.py +73 -0
  1120. templates/react-native/skills/react-native-mobile/versions.json +16 -0
  1121. templates/react-native/skills/react-native-patterns/SKILL.md +512 -0
  1122. templates/react-native/skills/react-native-patterns/assets/rn-review-checklist.md +26 -0
  1123. templates/react-native/skills/react-native-patterns/references/anatomy.md +62 -0
  1124. templates/react-native/skills/react-native-patterns/references/list-performance.md +70 -0
  1125. templates/react-native/skills/react-native-patterns/scripts/scan_rn_perf.py +77 -0
  1126. templates/react-native/stack.yaml +70 -0
  1127. templates/ruby-plain/scaffold/src/backend/Gemfile +8 -0
  1128. templates/ruby-plain/scaffold/src/backend/main.rb +3 -0
  1129. templates/ruby-plain/scaffold-boundary.yaml +23 -0
  1130. templates/ruby-plain/stack.yaml +50 -0
  1131. templates/rust-axum/rules/backend.md +20 -0
  1132. templates/rust-axum/scaffold/docs/engineering/rust-axum-rules.md +35 -0
  1133. templates/rust-axum/scaffold/docs/playbooks/rust-axum-service.md +43 -0
  1134. templates/rust-axum/scaffold/src/backend/Cargo.toml +20 -0
  1135. templates/rust-axum/scaffold/src/backend/src/app.rs +12 -0
  1136. templates/rust-axum/scaffold/src/backend/src/error.rs +48 -0
  1137. templates/rust-axum/scaffold/src/backend/src/main.rs +24 -0
  1138. templates/rust-axum/scaffold/src/backend/src/routes/health.rs +37 -0
  1139. templates/rust-axum/scaffold/src/backend/src/routes/mod.rs +2 -0
  1140. templates/rust-axum/scaffold-boundary.yaml +25 -0
  1141. templates/rust-axum/skills/rust/SKILL.md +73 -0
  1142. templates/rust-axum/skills/rust/references/anatomy.md +63 -0
  1143. templates/rust-axum/stack.yaml +65 -0
  1144. templates/rust-plain/scaffold/src/backend/Cargo.toml +6 -0
  1145. templates/rust-plain/scaffold/src/backend/src/main.rs +3 -0
  1146. templates/rust-plain/scaffold-boundary.yaml +23 -0
  1147. templates/rust-plain/stack.yaml +51 -0
  1148. templates/spring-boot/rules/backend.md +20 -0
  1149. templates/spring-boot/scaffold/docs/engineering/spring-boot-rules.md +35 -0
  1150. templates/spring-boot/scaffold/docs/playbooks/spring-boot-service.md +45 -0
  1151. templates/spring-boot/scaffold/src/backend/mvnw +302 -0
  1152. templates/spring-boot/scaffold/src/backend/pom.xml +69 -0
  1153. templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/Application.java +16 -0
  1154. templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/common/GlobalExceptionHandler.java +31 -0
  1155. templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/health/HealthController.java +22 -0
  1156. templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/health/HealthService.java +12 -0
  1157. templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/health/HealthStatus.java +4 -0
  1158. templates/spring-boot/scaffold/src/backend/src/test/java/com/example/app/health/HealthServiceTest.java +15 -0
  1159. templates/spring-boot/scaffold-boundary.yaml +26 -0
  1160. templates/spring-boot/skills/spring-boot/SKILL.md +84 -0
  1161. templates/spring-boot/skills/spring-boot/references/anatomy.md +63 -0
  1162. templates/spring-boot/stack.yaml +65 -0
  1163. templates/svelte-sveltekit/rules/frontend.md +20 -0
  1164. templates/svelte-sveltekit/scaffold/docs/engineering/svelte-sveltekit-rules.md +37 -0
  1165. templates/svelte-sveltekit/scaffold/docs/playbooks/svelte-sveltekit-app.md +36 -0
  1166. templates/svelte-sveltekit/scaffold/src/frontend/package.json +22 -0
  1167. templates/svelte-sveltekit/scaffold/src/frontend/src/app.html +12 -0
  1168. templates/svelte-sveltekit/scaffold/src/frontend/src/hooks.server.ts +14 -0
  1169. templates/svelte-sveltekit/scaffold/src/frontend/src/lib/components/Greeting.svelte +6 -0
  1170. templates/svelte-sveltekit/scaffold/src/frontend/src/lib/stores/count.test.ts +26 -0
  1171. templates/svelte-sveltekit/scaffold/src/frontend/src/lib/stores/count.ts +4 -0
  1172. templates/svelte-sveltekit/scaffold/src/frontend/src/routes/+layout.svelte +24 -0
  1173. templates/svelte-sveltekit/scaffold/src/frontend/src/routes/+page.svelte +9 -0
  1174. templates/svelte-sveltekit/scaffold/src/frontend/src/routes/+page.ts +7 -0
  1175. templates/svelte-sveltekit/scaffold/src/frontend/src/routes/health/+server.ts +7 -0
  1176. templates/svelte-sveltekit/scaffold/src/frontend/svelte.config.js +10 -0
  1177. templates/svelte-sveltekit/scaffold/src/frontend/tsconfig.json +7 -0
  1178. templates/svelte-sveltekit/scaffold/src/frontend/vite.config.ts +7 -0
  1179. templates/svelte-sveltekit/scaffold/src/frontend/vitest.config.ts +11 -0
  1180. templates/svelte-sveltekit/scaffold-boundary.yaml +29 -0
  1181. templates/svelte-sveltekit/skills/svelte/SKILL.md +90 -0
  1182. templates/svelte-sveltekit/skills/svelte/references/anatomy.md +62 -0
  1183. templates/svelte-sveltekit/stack.yaml +70 -0
  1184. templates/typescript-plain/scaffold/src/index.ts +3 -0
  1185. templates/typescript-plain/scaffold/tsconfig.json +13 -0
  1186. templates/typescript-plain/scaffold-boundary.yaml +22 -0
  1187. templates/typescript-plain/stack.yaml +44 -0
  1188. templates/vue-nuxt/rules/frontend.md +19 -0
  1189. templates/vue-nuxt/scaffold/docs/engineering/nuxt-rules.md +30 -0
  1190. templates/vue-nuxt/scaffold/docs/playbooks/nuxt-app.md +29 -0
  1191. templates/vue-nuxt/scaffold/src/frontend/app.vue +3 -0
  1192. templates/vue-nuxt/scaffold/src/frontend/nuxt.config.ts +11 -0
  1193. templates/vue-nuxt/scaffold/src/frontend/package.json +20 -0
  1194. templates/vue-nuxt/scaffold/src/frontend/pages/index.test.ts +19 -0
  1195. templates/vue-nuxt/scaffold/src/frontend/pages/index.vue +11 -0
  1196. templates/vue-nuxt/scaffold/src/frontend/vitest.config.ts +12 -0
  1197. templates/vue-nuxt/scaffold-boundary.yaml +26 -0
  1198. templates/vue-nuxt/skills/vue-nuxt/SKILL.md +57 -0
  1199. templates/vue-nuxt/skills/vue-nuxt/references/anatomy.md +60 -0
  1200. templates/vue-nuxt/stack.yaml +61 -0
  1201. templates/wordpress/rules/backend.md +19 -0
  1202. templates/wordpress/scaffold/docs/engineering/wordpress-rules.md +28 -0
  1203. templates/wordpress/scaffold/docs/playbooks/wordpress-service.md +29 -0
  1204. templates/wordpress/scaffold/src/backend/composer.json +17 -0
  1205. templates/wordpress/scaffold/src/backend/phpcs.xml.dist +11 -0
  1206. templates/wordpress/scaffold/src/backend/phpunit.xml +10 -0
  1207. templates/wordpress/scaffold/src/backend/plugin/inc/health.php +8 -0
  1208. templates/wordpress/scaffold/src/backend/plugin/plugin.php +28 -0
  1209. templates/wordpress/scaffold/src/backend/tests/HealthStatusTest.php +15 -0
  1210. templates/wordpress/scaffold/src/backend/theme/functions.php +18 -0
  1211. templates/wordpress/scaffold/src/backend/theme/style.css +11 -0
  1212. templates/wordpress/scaffold-boundary.yaml +23 -0
  1213. templates/wordpress/skills/wordpress/SKILL.md +110 -0
  1214. templates/wordpress/skills/wordpress/assets/wp-checklist.md +28 -0
  1215. templates/wordpress/skills/wordpress/references/wp-development.md +75 -0
  1216. templates/wordpress/skills/wordpress/references/wp-security.md +68 -0
  1217. templates/wordpress/skills/wordpress/scripts/scan_wp_smells.py +91 -0
  1218. templates/wordpress/skills/wordpress/versions.json +16 -0
  1219. templates/wordpress/stack.yaml +60 -0
  1220. thinking_os/__init__.py +1 -0
  1221. thinking_os/_agent_markers.py +32 -0
  1222. thinking_os/background.py +405 -0
  1223. thinking_os/bootstrap_outcomes.py +200 -0
  1224. thinking_os/budget.py +302 -0
  1225. thinking_os/capture.py +495 -0
  1226. thinking_os/cognition.py +516 -0
  1227. thinking_os/cognition_schemas.py +517 -0
  1228. thinking_os/compress.py +192 -0
  1229. thinking_os/concepts.py +233 -0
  1230. thinking_os/dashboard.py +159 -0
  1231. thinking_os/database.py +2883 -0
  1232. thinking_os/decay.py +393 -0
  1233. thinking_os/digest.py +295 -0
  1234. thinking_os/dispatcher.py +192 -0
  1235. thinking_os/dispatcher_helpers.py +48 -0
  1236. thinking_os/dispatchers/__init__.py +3 -0
  1237. thinking_os/dispatchers/default.py +47 -0
  1238. thinking_os/distill.py +192 -0
  1239. thinking_os/doc_indexer.py +905 -0
  1240. thinking_os/embeddings.py +943 -0
  1241. thinking_os/formula_composer.py +556 -0
  1242. thinking_os/gate_marker.py +75 -0
  1243. thinking_os/graph.py +296 -0
  1244. thinking_os/graph_indexer.py +360 -0
  1245. thinking_os/health_check.py +517 -0
  1246. thinking_os/impact.py +119 -0
  1247. thinking_os/memory_gc.py +356 -0
  1248. thinking_os/migrator_embeddings.py +318 -0
  1249. thinking_os/precision.py +194 -0
  1250. thinking_os/record_outcome.py +399 -0
  1251. thinking_os/repair.py +105 -0
  1252. thinking_os/retrieval_quality.py +239 -0
  1253. thinking_os/roles_state.py +168 -0
  1254. thinking_os/sanitizer.py +320 -0
  1255. thinking_os/server.py +3160 -0
  1256. thinking_os/session_enrich.py +272 -0
  1257. thinking_os/session_observe_worker.py +111 -0
  1258. thinking_os/session_startup.py +74 -0
  1259. thinking_os/session_summary.py +245 -0
  1260. thinking_os/task_analyzer.py +462 -0
  1261. thinking_os/task_parser.py +342 -0
  1262. thinking_os/task_sync.py +73 -0
  1263. thinking_os/tools/__init__.py +6 -0
  1264. thinking_os/tools/_shared.py +947 -0
  1265. thinking_os/tools/cognition.py +1867 -0
  1266. thinking_os/tools/docs.py +770 -0
  1267. thinking_os/tools/learning.py +2078 -0
  1268. thinking_os/tools/logs.py +79 -0
  1269. thinking_os/tools/memory.py +840 -0
  1270. thinking_os/tools/metrics.py +200 -0
  1271. thinking_os/tools/retrieve.py +415 -0
  1272. thinking_os/tools/routing.py +658 -0
  1273. thinking_os/tools/tasks.py +449 -0
  1274. thinking_os/tools/trajectory.py +181 -0
  1275. thinking_os/tracing.py +235 -0
  1276. web/__init__.py +5 -0
  1277. web/_cache.py +118 -0
  1278. web/_deps.py +56 -0
  1279. web/_envelope.py +85 -0
  1280. web/_project_context.py +140 -0
  1281. web/chat_providers.py +108 -0
  1282. web/init_jobs.py +216 -0
  1283. web/routes/__init__.py +25 -0
  1284. web/routes/_bounded_read.py +76 -0
  1285. web/routes/board.py +1089 -0
  1286. web/routes/cognition.py +1838 -0
  1287. web/routes/config.py +635 -0
  1288. web/routes/graph.py +513 -0
  1289. web/routes/health.py +180 -0
  1290. web/routes/hooks.py +288 -0
  1291. web/routes/hub.py +1219 -0
  1292. web/routes/logs.py +374 -0
  1293. web/routes/metrics.py +43 -0
  1294. web/routes/observability.py +400 -0
  1295. web/routes/patterns.py +227 -0
  1296. web/routes/presence.py +609 -0
  1297. web/routes/roles.py +446 -0
  1298. web/routes/scheduled.py +261 -0
  1299. web/routes/search.py +238 -0
  1300. web/routes/sessions.py +220 -0
  1301. web/routes/settings.py +363 -0
  1302. web/routes/stream.py +547 -0
  1303. web/security.py +157 -0
  1304. web/server.py +283 -0
cli/doctor.py ADDED
@@ -0,0 +1,2953 @@
1
+ """`cos doctor` — deep health check for an initialized coding-os project.
2
+
3
+ Checks (fail-fast ordering):
4
+
5
+ config.file_present .coding-os.yaml exists and parses
6
+ state.directory_present state dir exists
7
+ database.openable coding-os.db opens
8
+ database.schema_current schema_version == 6
9
+ database.tables_present core tables present
10
+ scaffold.roots_present scaffold roots exist (AGENTS.md, Makefile, docs/)
11
+ adapter.configured adapter-specific (Claude settings.json + hook executability, or
12
+ Codex hooks.json)
13
+ scaffold.placeholders_resolved no unresolved {{placeholder}} in scaffold text files
14
+ scheduled.cron_configured nightly cron: plist installed, loaded, no failures, recent run
15
+
16
+ scaffold.manifest_fresh (manifest hash diff) and mcp.self_test_passes (MCP self-test) are wired in Phase 2.
17
+
18
+ Severity semantics (plan D9):
19
+ PASS — expected state
20
+ WARN — drift / extras / minor inconsistencies (exit 0)
21
+ FAIL — missing critical file / broken invariant (exit 1)
22
+ --strict promotes WARN to exit 1.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import contextlib
28
+ import json
29
+ import logging
30
+ import os
31
+ import re
32
+ import sqlite3
33
+ import subprocess
34
+ import sys
35
+ from dataclasses import asdict, dataclass, field
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ import click
40
+ import yaml
41
+
42
+ from cli._resources import adapters_dir, core_dir, data_root, templates_dir
43
+ from cli.core_version import current_core_version, read_stamped_version
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # Bundled trees resolve via importlib (TASK-219) — survives wheel installs and
48
+ # meta-repo moves. CODING_OS_ROOT remains for repo-only assets (docs/) that
49
+ # exist solely in a source checkout.
50
+ CODING_OS_ROOT = data_root().parent
51
+ MANIFEST_PATH_DEFAULT = core_dir("scaffold_manifest.json")
52
+ MCP_SERVER_PATH = core_dir("thinking_os", "server.py")
53
+
54
+
55
+ def _load_runtime_paths() -> tuple[frozenset[str], tuple[str, ...]]:
56
+ """Load runtime_files + ignored_prefixes from src/core/runtime_paths.yaml.
57
+
58
+ Returns (runtime_files_set, ignored_prefixes_tuple). On missing/invalid
59
+ config, falls back to empty sets so doctor never crashes on config errors.
60
+ """
61
+ path = core_dir("runtime_paths.yaml")
62
+ try:
63
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
64
+ except (OSError, yaml.YAMLError) as exc:
65
+ logger.warning("cannot load runtime_paths.yaml: %s", exc)
66
+ return frozenset(), ()
67
+ runtime = frozenset(str(p) for p in (data.get("runtime_files") or []))
68
+ prefixes = tuple(str(p) for p in (data.get("ignored_prefixes") or []))
69
+ return runtime, prefixes
70
+
71
+
72
+ def _load_doctor_config() -> dict[str, Any]:
73
+ """Load src/core/doctor-config.yaml. Returns {} on failure."""
74
+ path = core_dir("doctor-config.yaml")
75
+ try:
76
+ return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
77
+ except (OSError, yaml.YAMLError) as exc:
78
+ logger.warning("cannot load doctor-config.yaml: %s", exc)
79
+ return {}
80
+
81
+
82
+ # ---- Module-level configuration (loaded once at import) ----------------
83
+ RUNTIME_PATHS, IGNORED_PREFIXES = _load_runtime_paths()
84
+ _DOCTOR_CFG = _load_doctor_config()
85
+
86
+
87
+ def _scan_project_files(project: Path) -> set[str]:
88
+ """Project file set, pruning ignored top-level subtrees in place.
89
+
90
+ os.walk lets us drop .git/.venv/node_modules/.build from `dirnames` so we
91
+ never descend into them — a full rglob walked those heavy trees before
92
+ filtering, the dominant cost on a 100K-file repo. TASK-227.
93
+ """
94
+ proot = project.resolve()
95
+ actual: set[str] = set()
96
+
97
+ def _ignored_dir(rel_dir: str, name: str) -> bool:
98
+ child = f"{rel_dir}/{name}/" if rel_dir else f"{name}/"
99
+ return any(child.startswith(p) for p in IGNORED_PREFIXES)
100
+
101
+ for dirpath, dirnames, filenames in os.walk(proot):
102
+ rel_dir = os.path.relpath(dirpath, proot)
103
+ rel_dir = "" if rel_dir == "." else rel_dir.replace(os.sep, "/")
104
+ dirnames[:] = [d for d in dirnames if not _ignored_dir(rel_dir, d)]
105
+ for fn in filenames:
106
+ rel = f"{rel_dir}/{fn}" if rel_dir else fn
107
+ if rel in RUNTIME_PATHS:
108
+ continue
109
+ if any(rel.startswith(p) for p in IGNORED_PREFIXES):
110
+ continue
111
+ actual.add(rel)
112
+ return actual
113
+
114
+
115
+ CONFIG_FILE = ".coding-os.yaml"
116
+ STATE_DIR_DEFAULT = ".coding-os"
117
+
118
+ _schema_cfg = _DOCTOR_CFG.get("schema") or {}
119
+
120
+
121
+ def _derive_expected_schema_version() -> int:
122
+ """Read max migration version from thinking_os.database.MIGRATIONS (SSOT).
123
+
124
+ Falls back to the doctor-config.yaml mirror if the import fails (fresh
125
+ clone before .venv install, broken module). Eliminates the drift class
126
+ where a new migration lands but doctor-config wasn't bumped.
127
+ """
128
+ try:
129
+ from core.thinking_os.database import MIGRATIONS
130
+
131
+ return max(int(m[0]) for m in MIGRATIONS)
132
+ except Exception:
133
+ return int(_schema_cfg.get("expected_version", 6))
134
+
135
+
136
+ EXPECTED_SCHEMA_VERSION: int = _derive_expected_schema_version()
137
+ EXPECTED_TABLES: frozenset[str] = frozenset(_schema_cfg.get("expected_tables") or ())
138
+
139
+ # Note: `sourced_hooks` is per-adapter (src/adapters/<id>/adapter.yaml) and is
140
+ # read by _check_adapter directly from the AdapterProfile. There is no
141
+ # longer a cross-adapter hardcoded fallback here.
142
+
143
+ _scan_cfg = _DOCTOR_CFG.get("placeholder_scan") or {}
144
+ PLACEHOLDER_RE = re.compile(r"\{\{[a-zA-Z_][a-zA-Z0-9_.]*\}\}")
145
+ PLACEHOLDER_SCAN_EXTENSIONS: frozenset[str] = frozenset(
146
+ _scan_cfg.get("extensions") or (".md", ".json", ".yaml", ".yml", ".sh", ".py", ".toml", ".txt")
147
+ )
148
+ PLACEHOLDER_SCAN_NAMES: frozenset[str] = frozenset(_scan_cfg.get("file_names") or ("Makefile",))
149
+ PLACEHOLDER_MAX_BYTES: int = int(_scan_cfg.get("max_bytes") or 262144)
150
+ PLACEHOLDER_SCAN_ROOTS: tuple[str, ...] = tuple(
151
+ _scan_cfg.get("root_paths") or ("AGENTS.md", "Makefile", "docs", ".coding-os.yaml")
152
+ )
153
+ PLACEHOLDER_SCAN_SKIP: tuple[str, ...] = tuple(
154
+ _scan_cfg.get("skip_paths") or ("docs/governance/templates",)
155
+ )
156
+
157
+ SEV_PASS = "PASS"
158
+ SEV_WARN = "WARN"
159
+ SEV_FAIL = "FAIL"
160
+
161
+
162
+ @dataclass
163
+ class CheckResult:
164
+ id: str
165
+ severity: str
166
+ message: str
167
+ details: dict[str, Any] = field(default_factory=dict)
168
+
169
+ @property
170
+ def category(self) -> str:
171
+ return self.id.split(".", 1)[0] if "." in self.id else self.id
172
+
173
+ @property
174
+ def name(self) -> str:
175
+ return self.id.split(".", 1)[1] if "." in self.id else ""
176
+
177
+
178
+ @dataclass
179
+ class DoctorReport:
180
+ project_dir: str
181
+ agent: str | None
182
+ templates: list[str]
183
+ checks: list[CheckResult] = field(default_factory=list)
184
+ suppressed: int = 0
185
+ suppressed_globs: list[str] = field(default_factory=list)
186
+
187
+ def summary(self) -> dict[str, int]:
188
+ pass_n = sum(1 for c in self.checks if c.severity == SEV_PASS)
189
+ warn_n = sum(1 for c in self.checks if c.severity == SEV_WARN)
190
+ fail_n = sum(1 for c in self.checks if c.severity == SEV_FAIL)
191
+ return {"pass": pass_n, "warn": warn_n, "fail": fail_n}
192
+
193
+ def exit_code(self, *, strict: bool) -> int:
194
+ s = self.summary()
195
+ if s["fail"]:
196
+ return 1
197
+ if strict and s["warn"]:
198
+ return 1
199
+ return 0
200
+
201
+
202
+ def _check_config(project: Path, report: DoctorReport) -> dict[str, Any] | None:
203
+ """config.file_present — .coding-os.yaml exists and parses. Fatal if missing."""
204
+ config_path = project / CONFIG_FILE
205
+ if not config_path.exists():
206
+ report.checks.append(
207
+ CheckResult(
208
+ "config.file_present",
209
+ SEV_FAIL,
210
+ f"{CONFIG_FILE} not found — run `cos init --agent <claude|codex>`",
211
+ {"path": str(config_path)},
212
+ )
213
+ )
214
+ return None
215
+ try:
216
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
217
+ except yaml.YAMLError as exc:
218
+ report.checks.append(
219
+ CheckResult(
220
+ "config.file_present",
221
+ SEV_FAIL,
222
+ f"{CONFIG_FILE} is not valid YAML: {exc}",
223
+ {"path": str(config_path)},
224
+ )
225
+ )
226
+ return None
227
+ report.checks.append(
228
+ CheckResult("config.file_present", SEV_PASS, "valid", {"keys": sorted(data.keys())})
229
+ )
230
+ report.agent = (data.get("agents") or [None])[0]
231
+ report.templates = list(data.get("templates") or [])
232
+ return data
233
+
234
+
235
+ def _check_state_dir(project: Path, config: dict[str, Any], report: DoctorReport) -> Path:
236
+ """state.directory_present — state dir exists."""
237
+ state = project / config.get("state_dir", STATE_DIR_DEFAULT)
238
+ if not state.is_dir():
239
+ report.checks.append(
240
+ CheckResult(
241
+ "state.directory_present",
242
+ SEV_FAIL,
243
+ "state directory missing",
244
+ {"path": str(state)},
245
+ )
246
+ )
247
+ else:
248
+ report.checks.append(
249
+ CheckResult(
250
+ "state.directory_present",
251
+ SEV_PASS,
252
+ "present",
253
+ {"path": str(state)},
254
+ )
255
+ )
256
+ return state
257
+
258
+
259
+ def _check_database(state: Path, report: DoctorReport) -> sqlite3.Connection | None:
260
+ """database.openable + database.schema_current + database.tables_present — DB opens, schema version 6, all 11 tables present."""
261
+ db_path = state / "coding-os.db"
262
+ if not db_path.exists():
263
+ report.checks.append(
264
+ CheckResult(
265
+ "database.openable",
266
+ SEV_FAIL,
267
+ "coding-os.db not found",
268
+ {"path": str(db_path)},
269
+ )
270
+ )
271
+ return None
272
+ try:
273
+ conn = sqlite3.connect(str(db_path), timeout=5)
274
+ except sqlite3.Error as exc:
275
+ report.checks.append(
276
+ CheckResult(
277
+ "database.openable",
278
+ SEV_FAIL,
279
+ f"cannot open DB: {exc}",
280
+ {"path": str(db_path)},
281
+ )
282
+ )
283
+ return None
284
+ report.checks.append(
285
+ CheckResult("database.openable", SEV_PASS, "opened", {"path": str(db_path)})
286
+ )
287
+
288
+ try:
289
+ cur = conn.execute("SELECT MAX(version) FROM schema_version")
290
+ row = cur.fetchone()
291
+ version = int(row[0]) if row and row[0] is not None else None
292
+ except sqlite3.Error as exc:
293
+ report.checks.append(
294
+ CheckResult(
295
+ "database.schema_current",
296
+ SEV_FAIL,
297
+ f"schema_version query failed: {exc}",
298
+ )
299
+ )
300
+ version = None
301
+
302
+ if version is None:
303
+ pass # already reported
304
+ elif version < EXPECTED_SCHEMA_VERSION:
305
+ report.checks.append(
306
+ CheckResult(
307
+ "database.schema_current",
308
+ SEV_FAIL,
309
+ f"schema version {version} < expected {EXPECTED_SCHEMA_VERSION}",
310
+ {"actual": version, "expected": EXPECTED_SCHEMA_VERSION},
311
+ )
312
+ )
313
+ elif version > EXPECTED_SCHEMA_VERSION:
314
+ report.checks.append(
315
+ CheckResult(
316
+ "database.schema_current",
317
+ SEV_WARN,
318
+ f"schema version {version} newer than expected {EXPECTED_SCHEMA_VERSION}",
319
+ {"actual": version, "expected": EXPECTED_SCHEMA_VERSION},
320
+ )
321
+ )
322
+ else:
323
+ report.checks.append(
324
+ CheckResult(
325
+ "database.schema_current",
326
+ SEV_PASS,
327
+ f"v{version}",
328
+ {"actual": version},
329
+ )
330
+ )
331
+
332
+ try:
333
+ cur = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
334
+ actual = {row[0] for row in cur.fetchall()}
335
+ except sqlite3.Error as exc:
336
+ report.checks.append(
337
+ CheckResult("database.tables_present", SEV_FAIL, f"table list failed: {exc}")
338
+ )
339
+ return conn
340
+
341
+ missing = sorted(EXPECTED_TABLES - actual)
342
+ if missing:
343
+ report.checks.append(
344
+ CheckResult(
345
+ "database.tables_present",
346
+ SEV_FAIL,
347
+ f"missing tables: {', '.join(missing)}",
348
+ {"missing": missing, "found": sorted(actual)},
349
+ )
350
+ )
351
+ else:
352
+ report.checks.append(
353
+ CheckResult(
354
+ "database.tables_present",
355
+ SEV_PASS,
356
+ f"all {len(EXPECTED_TABLES)} core tables present",
357
+ {"count": len(actual)},
358
+ )
359
+ )
360
+ return conn
361
+
362
+
363
+ def _check_core_version(state: Path, report: DoctorReport) -> None:
364
+ """core.version_stamp — consumer's stamped core version vs the installed core (D6 drift)."""
365
+ current = current_core_version()
366
+ stamped = read_stamped_version(state)
367
+ if stamped is None:
368
+ report.checks.append(
369
+ CheckResult(
370
+ "core.version_stamp",
371
+ SEV_WARN,
372
+ "no core-version stamp — scaffolded before stamping; run `cos update`",
373
+ {"current": current},
374
+ )
375
+ )
376
+ elif stamped != current:
377
+ report.checks.append(
378
+ CheckResult(
379
+ "core.version_stamp",
380
+ SEV_WARN,
381
+ f"core drift — scaffolded by {stamped}, current core {current}; run `cos update`",
382
+ {"stamped": stamped, "current": current},
383
+ )
384
+ )
385
+ else:
386
+ report.checks.append(
387
+ CheckResult("core.version_stamp", SEV_PASS, f"core {current}", {"stamped": stamped})
388
+ )
389
+
390
+
391
+ def _check_scaffold_roots(project: Path, report: DoctorReport) -> None:
392
+ """scaffold.roots_present — AGENTS.md, Makefile, docs/ exist at project root."""
393
+ required = {
394
+ "AGENTS.md": project / "AGENTS.md",
395
+ "Makefile": project / "Makefile",
396
+ "docs/": project / "docs",
397
+ }
398
+ missing = [name for name, path in required.items() if not path.exists()]
399
+ if missing:
400
+ report.checks.append(
401
+ CheckResult(
402
+ "scaffold.roots_present",
403
+ SEV_FAIL,
404
+ f"missing: {', '.join(missing)}",
405
+ {"missing": missing},
406
+ )
407
+ )
408
+ else:
409
+ report.checks.append(
410
+ CheckResult(
411
+ "scaffold.roots_present",
412
+ SEV_PASS,
413
+ "AGENTS.md, Makefile, docs/ all present",
414
+ )
415
+ )
416
+
417
+
418
+ # Top-level src/ subtrees the project anatomy permits (project-anatomy.md).
419
+ # Stacks own backend/services/frontend/mobile; shared/ is the polyglot reuse
420
+ # layer. Anything else directly under src/ is a stray subtree.
421
+ _ANATOMY_TOP_LEVEL = ("backend", "services", "frontend", "mobile", "shared")
422
+
423
+
424
+ def _declared_src_segments(project: Path, config: dict[str, Any] | None) -> set[str]:
425
+ """Top-level `src/<seg>/` segments each installed stack owns per the
426
+ aggregated scaffold-boundary.yaml — e.g. {"services", "frontend"} after
427
+ multi-backend relocation, or {"backend"} for a single backend. Empty when
428
+ no boundary file exists (fall back to the static anatomy allow-list)."""
429
+ state_name = (config or {}).get("state_dir", STATE_DIR_DEFAULT)
430
+ boundary = project / state_name / "scaffold-boundary.yaml"
431
+ if not boundary.is_file():
432
+ return set()
433
+ try:
434
+ data = yaml.safe_load(boundary.read_text(encoding="utf-8")) or {}
435
+ except (OSError, yaml.YAMLError):
436
+ return set()
437
+ segments: set[str] = set()
438
+ for stack in data.get("stacks") or []:
439
+ for root in stack.get("roots") or []:
440
+ parts = str(root).strip("/").split("/")
441
+ if len(parts) >= 2 and parts[0] == "src":
442
+ segments.add(parts[1])
443
+ return segments
444
+
445
+
446
+ def _check_structure(
447
+ project: Path, report: DoctorReport, config: dict[str, Any] | None = None
448
+ ) -> None:
449
+ """structure.* — validate the src/ tree against the declared project anatomy.
450
+
451
+ A compliant tree appends only PASS (exit 0). Each stray subtree — a
452
+ `src/<name>/` that is neither `shared` nor a declared/known anatomy root —
453
+ becomes one FAIL naming the expected location, so the exit code is 1."""
454
+ src = project / "src"
455
+ if not src.is_dir():
456
+ report.checks.append(
457
+ CheckResult("structure.src_present", SEV_PASS, "no src/ tree — nothing to validate")
458
+ )
459
+ return
460
+
461
+ # Only a project that DECLARED an anatomy (installed stacks → aggregated
462
+ # scaffold-boundary.yaml) is validated. A base-only consumer or a non-
463
+ # consumer tree (e.g. the meta-repo itself) never declared one, so there is
464
+ # nothing to validate against — emit PASS rather than flag its own layout.
465
+ state_name = (config or {}).get("state_dir", STATE_DIR_DEFAULT)
466
+ if not (project / state_name / "scaffold-boundary.yaml").is_file():
467
+ report.checks.append(
468
+ CheckResult(
469
+ "structure.not_declared",
470
+ SEV_PASS,
471
+ "no scaffold-boundary.yaml — no declared anatomy to validate",
472
+ )
473
+ )
474
+ return
475
+
476
+ # `declared` (from the aggregated boundary) is used ONLY to detect the
477
+ # services/ layout — so a top-level src/backend/ in a project that placed
478
+ # its backends under src/services/ is flagged as misplaced. The five known
479
+ # anatomy slots are always permitted, so a hand-added src/frontend/ without
480
+ # a registered frontend stack is never a false positive.
481
+ declared = _declared_src_segments(project, config)
482
+ services_layout = "services" in declared
483
+ known = set(_ANATOMY_TOP_LEVEL)
484
+
485
+ stray = 0
486
+ for child in sorted(p for p in src.iterdir() if p.is_dir()):
487
+ name = child.name
488
+ if services_layout and name == "backend":
489
+ expected = (
490
+ "src/services/<stack-id>/ — this project uses the services/ "
491
+ "layout, so a top-level src/backend/ is misplaced"
492
+ )
493
+ elif name in known:
494
+ continue
495
+ else:
496
+ expected = (
497
+ f"a declared anatomy subtree ({', '.join(_ANATOMY_TOP_LEVEL)}); "
498
+ "services under src/services/<name>/, shared code under src/shared/"
499
+ )
500
+ report.checks.append(
501
+ CheckResult(
502
+ f"structure.stray.{name}",
503
+ SEV_FAIL,
504
+ f"src/{name}/ violates declared anatomy — expected: {expected}",
505
+ {"path": f"src/{name}", "expected": expected},
506
+ )
507
+ )
508
+ stray += 1
509
+
510
+ if stray == 0:
511
+ report.checks.append(
512
+ CheckResult(
513
+ "structure.anatomy",
514
+ SEV_PASS,
515
+ "src/ tree matches the declared anatomy",
516
+ {"known": sorted(known)},
517
+ )
518
+ )
519
+
520
+
521
+ def _check_adapter(project: Path, agent: str | None, report: DoctorReport) -> None:
522
+ """adapter.configured — adapter-specific files, driven entirely by src/adapters/<id>/adapter.yaml.
523
+
524
+ Previously had hardcoded if/elif branches for claude + codex. Now we
525
+ load the adapter profile and:
526
+ - validate its declared settings_file is valid JSON
527
+ - if it declares a hooks_dir, validate every .sh file is executable
528
+ (skipping files listed in sourced_hooks)
529
+ No new Python code is needed to support a new adapter — just add
530
+ `src/adapters/<id>/adapter.yaml` and `install.sh`.
531
+ """
532
+ if agent is None:
533
+ report.checks.append(CheckResult("adapter.configured", SEV_FAIL, "agent not set in config"))
534
+ return
535
+
536
+ try:
537
+ # Late import to keep doctor usable even if adapter_registry has issues
538
+ from cli.adapter_registry import load_adapter_registry
539
+
540
+ adapters = load_adapter_registry(adapters_dir())
541
+ except Exception as exc:
542
+ report.checks.append(
543
+ CheckResult(
544
+ "adapter.configured",
545
+ SEV_WARN,
546
+ f"could not load adapter registry: {exc}",
547
+ )
548
+ )
549
+ return
550
+
551
+ if agent not in adapters:
552
+ report.checks.append(
553
+ CheckResult(
554
+ "adapter.configured",
555
+ SEV_WARN,
556
+ f"no adapter manifest for agent '{agent}'",
557
+ )
558
+ )
559
+ return
560
+
561
+ profile = adapters[agent]
562
+
563
+ # 1. Validate declared settings file (if any) is parseable JSON.
564
+ if profile.settings_file and profile.supports_settings_json:
565
+ settings_path = project / profile.settings_file
566
+ if not settings_path.exists():
567
+ report.checks.append(
568
+ CheckResult(
569
+ "adapter.configured",
570
+ SEV_FAIL,
571
+ f"{profile.settings_file} not found",
572
+ {"path": str(settings_path)},
573
+ )
574
+ )
575
+ return
576
+ try:
577
+ json.loads(settings_path.read_text(encoding="utf-8"))
578
+ except json.JSONDecodeError as exc:
579
+ report.checks.append(
580
+ CheckResult(
581
+ "adapter.configured",
582
+ SEV_FAIL,
583
+ f"{profile.settings_file} invalid JSON: {exc}",
584
+ )
585
+ )
586
+ return
587
+
588
+ # 2. Validate hooks dir (if declared): every .sh executable, except sourced ones.
589
+ hook_count = 0
590
+ if profile.hooks_dir:
591
+ hooks_dir = project / profile.hooks_dir
592
+ if not hooks_dir.is_dir():
593
+ report.checks.append(
594
+ CheckResult(
595
+ "adapter.configured",
596
+ SEV_FAIL,
597
+ f"{profile.hooks_dir} not found",
598
+ )
599
+ )
600
+ return
601
+ sourced = set(profile.sourced_hooks)
602
+ hook_files = [h for h in sorted(hooks_dir.glob("*.sh")) if h.name not in sourced]
603
+ broken_symlinks = [h.name for h in hook_files if h.is_symlink() and not h.exists()]
604
+ if broken_symlinks:
605
+ report.checks.append(
606
+ CheckResult(
607
+ "adapter.configured",
608
+ SEV_FAIL,
609
+ f"broken hook symlinks: {', '.join(broken_symlinks[:5])}"
610
+ + (f" (+{len(broken_symlinks) - 5} more)" if len(broken_symlinks) > 5 else "")
611
+ + " — run: cos install",
612
+ {"broken_symlinks": broken_symlinks},
613
+ )
614
+ )
615
+ return
616
+ non_exec = [h.name for h in hook_files if not (h.stat().st_mode & 0o111)]
617
+ if non_exec:
618
+ report.checks.append(
619
+ CheckResult(
620
+ "adapter.configured",
621
+ SEV_FAIL,
622
+ f"hooks not executable: {', '.join(non_exec)}",
623
+ {"non_executable": non_exec},
624
+ )
625
+ )
626
+ return
627
+ hook_count = len(hook_files)
628
+
629
+ # 3. PASS
630
+ if profile.hooks_dir:
631
+ msg = f"{profile.settings_file or 'settings'} valid, {hook_count} hooks executable"
632
+ else:
633
+ msg = f"{profile.settings_file or 'manifest'} valid"
634
+ report.checks.append(
635
+ CheckResult(
636
+ "adapter.configured",
637
+ SEV_PASS,
638
+ msg,
639
+ {"hook_count": hook_count},
640
+ )
641
+ )
642
+
643
+
644
+ def _check_placeholders(project: Path, report: DoctorReport) -> None:
645
+ """scaffold.placeholders_resolved — no unresolved {{placeholder}} in scaffold text files.
646
+
647
+ Scan roots come from src/core/doctor-config.yaml::placeholder_scan.root_paths,
648
+ plus every adapter's declared rules_dir, hooks_dir, and skills_dir (from
649
+ the adapter registry) so Codex-style extras are discovered automatically.
650
+ """
651
+ offenders: list[dict[str, Any]] = []
652
+ scan_roots = [project / root for root in PLACEHOLDER_SCAN_ROOTS]
653
+
654
+ # Append adapter-declared directories so placeholders inside e.g.
655
+ # .claude/rules/ or .codex/instructions/ are caught.
656
+ try:
657
+ from cli.adapter_registry import load_adapter_registry
658
+
659
+ adapters = load_adapter_registry(adapters_dir())
660
+ except Exception as exc:
661
+ logger.debug("adapter registry skipped for placeholder scan: %s", exc)
662
+ adapters = {}
663
+ for profile in adapters.values():
664
+ for attr in ("settings_file", "hooks_dir", "rules_dir", "skills_dir"):
665
+ value = getattr(profile, attr)
666
+ if value:
667
+ candidate = project / value
668
+ if candidate not in scan_roots:
669
+ scan_roots.append(candidate)
670
+
671
+ for root in scan_roots:
672
+ if not root.exists():
673
+ continue
674
+ targets = [root] if root.is_file() else list(root.rglob("*"))
675
+ for f in targets:
676
+ if not f.is_file():
677
+ continue
678
+ if f.suffix not in PLACEHOLDER_SCAN_EXTENSIONS and f.name not in PLACEHOLDER_SCAN_NAMES:
679
+ continue
680
+ try:
681
+ rel_posix = f.relative_to(project).as_posix()
682
+ except ValueError:
683
+ rel_posix = ""
684
+ if any(
685
+ rel_posix == skip or rel_posix.startswith(skip + "/")
686
+ for skip in PLACEHOLDER_SCAN_SKIP
687
+ ):
688
+ continue
689
+ try:
690
+ if f.stat().st_size > PLACEHOLDER_MAX_BYTES:
691
+ continue
692
+ text = f.read_text(encoding="utf-8", errors="ignore")
693
+ except OSError:
694
+ continue
695
+ # Line-aware scan — skip sed-substitution rules (e.g.
696
+ # `sed -e 's|{{X}}|...|g'`) which contain placeholders that
697
+ # are pattern-side input to the rendering script itself, not
698
+ # unresolved leftovers.
699
+ matches: list[str] = []
700
+ for line in text.splitlines():
701
+ if "s|{{" in line or "s/{{" in line or "{{X}}" in line:
702
+ continue # sed substitution rule — intentional placeholder
703
+ matches.extend(PLACEHOLDER_RE.findall(line))
704
+ if matches:
705
+ offenders.append(
706
+ {"path": str(f.relative_to(project)), "placeholders": sorted(set(matches))}
707
+ )
708
+
709
+ if offenders:
710
+ report.checks.append(
711
+ CheckResult(
712
+ "scaffold.placeholders_resolved",
713
+ SEV_FAIL,
714
+ f"{len(offenders)} file(s) contain unresolved placeholders",
715
+ {"offenders": offenders[:20]},
716
+ )
717
+ )
718
+ else:
719
+ report.checks.append(
720
+ CheckResult(
721
+ "scaffold.placeholders_resolved",
722
+ SEV_PASS,
723
+ "no unresolved placeholders in scaffold files",
724
+ )
725
+ )
726
+
727
+
728
+ def _section_id(agent: str | None, templates: list[str]) -> str | None:
729
+ """Map (agent, templates) to a manifest section id."""
730
+ if agent is None:
731
+ return None
732
+ if not templates:
733
+ return f"{agent}_base"
734
+ if len(templates) == 1:
735
+ return f"{agent}_{templates[0]}"
736
+ return None # multi-template not tracked
737
+
738
+
739
+ def _check_manifest(
740
+ project: Path,
741
+ report: DoctorReport,
742
+ manifest_path: Path,
743
+ ) -> None:
744
+ """scaffold.manifest_fresh — compare project's file set against the section manifest.
745
+
746
+ Missing expected paths → FAIL. Extras → WARN (user may have added files).
747
+ """
748
+ section_id = _section_id(report.agent, report.templates)
749
+ if section_id is None:
750
+ # Multi-stack projects have no precomputed section (manifest only
751
+ # tracks single-stack combos). This is expected — file-by-file
752
+ # validation for arbitrary combinations is out of scope for scaffold.manifest_fresh.
753
+ report.checks.append(
754
+ CheckResult(
755
+ "scaffold.manifest_fresh",
756
+ SEV_PASS,
757
+ "multi-stack project — manifest diff not applicable",
758
+ {"agent": report.agent, "templates": report.templates},
759
+ )
760
+ )
761
+ return
762
+ # Meta-repo detection — if this project IS the coding-os source tree
763
+ # (src/cli/main.py + src/templates/_base/ both present), skip scaffold.manifest_fresh.
764
+ # Meta-repo is the FACTORY, not a consumer of itself — comparing it
765
+ # against a fresh `cos init -t meta` sandbox produces false missing.
766
+ if (project / "src" / "cli" / "main.py").exists() and (
767
+ project / "src" / "templates" / "_base"
768
+ ).is_dir():
769
+ report.checks.append(
770
+ CheckResult(
771
+ "scaffold.manifest_fresh",
772
+ SEV_PASS,
773
+ "meta-repo factory — manifest diff not applicable",
774
+ {"agent": report.agent, "templates": report.templates},
775
+ )
776
+ )
777
+ return
778
+ if not manifest_path.exists():
779
+ report.checks.append(
780
+ CheckResult(
781
+ "scaffold.manifest_fresh",
782
+ SEV_WARN,
783
+ f"manifest file not found at {manifest_path}",
784
+ )
785
+ )
786
+ return
787
+ try:
788
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
789
+ except json.JSONDecodeError as exc:
790
+ report.checks.append(
791
+ CheckResult(
792
+ "scaffold.manifest_fresh",
793
+ SEV_WARN,
794
+ f"manifest file invalid JSON: {exc}",
795
+ )
796
+ )
797
+ return
798
+
799
+ section = manifest.get("sections", {}).get(section_id)
800
+ if not section:
801
+ report.checks.append(
802
+ CheckResult(
803
+ "scaffold.manifest_fresh",
804
+ SEV_WARN,
805
+ f"manifest has no section '{section_id}'",
806
+ )
807
+ )
808
+ return
809
+
810
+ expected = set(section.get("paths", []))
811
+ actual = _scan_project_files(project)
812
+
813
+ missing = sorted(expected - actual)
814
+ extras = sorted(actual - expected)
815
+
816
+ if missing:
817
+ report.checks.append(
818
+ CheckResult(
819
+ "scaffold.manifest_fresh",
820
+ SEV_FAIL,
821
+ f"{len(missing)} expected file(s) missing",
822
+ {
823
+ "section": section_id,
824
+ "missing": missing[:20],
825
+ "missing_total": len(missing),
826
+ },
827
+ )
828
+ )
829
+ elif extras:
830
+ report.checks.append(
831
+ CheckResult(
832
+ "scaffold.manifest_fresh",
833
+ SEV_WARN,
834
+ f"{len(extras)} extra file(s) not in manifest",
835
+ {
836
+ "section": section_id,
837
+ "extras": extras[:20],
838
+ "extras_total": len(extras),
839
+ },
840
+ )
841
+ )
842
+ else:
843
+ report.checks.append(
844
+ CheckResult(
845
+ "scaffold.manifest_fresh",
846
+ SEV_PASS,
847
+ f"all {len(expected)} expected files present",
848
+ {"section": section_id, "count": len(expected)},
849
+ )
850
+ )
851
+
852
+
853
+ def _check_mcp_selftest(project: Path, report: DoctorReport) -> None:
854
+ """mcp.self_test_passes — run thinking_os MCP server self-test against the project DB."""
855
+ if not MCP_SERVER_PATH.exists():
856
+ report.checks.append(
857
+ CheckResult(
858
+ "mcp.self_test_passes",
859
+ SEV_WARN,
860
+ "MCP server.py not found in coding-os core",
861
+ )
862
+ )
863
+ return
864
+ db_path = project / ".coding-os" / "coding-os.db"
865
+ env = os.environ.copy()
866
+ env["COS_DB_PATH"] = str(db_path)
867
+ try:
868
+ proc = subprocess.run(
869
+ [sys.executable, str(MCP_SERVER_PATH), "--test"],
870
+ env=env,
871
+ capture_output=True,
872
+ text=True,
873
+ timeout=30,
874
+ check=False,
875
+ )
876
+ except subprocess.TimeoutExpired:
877
+ report.checks.append(
878
+ CheckResult("mcp.self_test_passes", SEV_FAIL, "self-test timed out (30s)")
879
+ )
880
+ return
881
+ except OSError as exc:
882
+ report.checks.append(CheckResult("mcp.self_test_passes", SEV_FAIL, f"cannot run: {exc}"))
883
+ return
884
+ if proc.returncode == 0:
885
+ report.checks.append(CheckResult("mcp.self_test_passes", SEV_PASS, "self-test passed"))
886
+ else:
887
+ report.checks.append(
888
+ CheckResult(
889
+ "mcp.self_test_passes",
890
+ SEV_FAIL,
891
+ f"self-test exit {proc.returncode}",
892
+ {"stderr": (proc.stderr or "")[-500:]},
893
+ )
894
+ )
895
+
896
+
897
+ def _ignore_globs_from_config(config: dict[str, Any]) -> list[str]:
898
+ raw = (config.get("doctor") or {}).get("ignore") or []
899
+ return [str(item) for item in raw if isinstance(item, (str, bytes))]
900
+
901
+
902
+ def _explain_check(check_id: str) -> str:
903
+ doc_path = CODING_OS_ROOT / "docs" / "playbooks" / "doctor-checks.md"
904
+ if not doc_path.exists():
905
+ return f"doctor-checks reference not found at {doc_path}"
906
+ text = doc_path.read_text(encoding="utf-8")
907
+ marker = f"### {check_id}"
908
+ start = text.find(marker)
909
+ if start < 0:
910
+ return (
911
+ f"no entry for '{check_id}' in {doc_path.name}.\n"
912
+ f"run `cos doctor --format json` to list every available ID."
913
+ )
914
+ end = text.find("\n### ", start + len(marker))
915
+ if end < 0:
916
+ end = text.find("\n---", start + len(marker))
917
+ if end < 0:
918
+ end = len(text)
919
+ return text[start:end].rstrip() + f"\n\n— source: {doc_path}"
920
+
921
+
922
+ def _suppress_checks(report: DoctorReport, ignore_globs: list[str]) -> int:
923
+ if not ignore_globs:
924
+ return 0
925
+ import fnmatch as _fnmatch
926
+
927
+ before = len(report.checks)
928
+ report.checks = [
929
+ c for c in report.checks if not any(_fnmatch.fnmatch(c.id, pat) for pat in ignore_globs)
930
+ ]
931
+ return before - len(report.checks)
932
+
933
+
934
+ def _tick(label: str) -> None:
935
+ """Stream a per-check progress line to stderr (interactive runs only)."""
936
+ if sys.stderr.isatty():
937
+ print(f" [doctor] {label}…", file=sys.stderr, flush=True)
938
+
939
+
940
+ def _check_runtime_errors(state: Path, report: DoctorReport) -> None:
941
+ """runtime.recent_errors — WARN/FAIL when the durable error store shows recent ERROR/FATAL."""
942
+ db_file = state / "coding-os.db"
943
+ if not db_file.exists():
944
+ report.checks.append(
945
+ CheckResult("runtime.recent_errors", SEV_PASS, "no durable error store yet")
946
+ )
947
+ return
948
+ try:
949
+ import sqlite3
950
+ from datetime import datetime, timedelta, timezone
951
+
952
+ conn = sqlite3.connect(str(db_file))
953
+ conn.row_factory = sqlite3.Row
954
+ if (
955
+ conn.execute(
956
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='log_events'"
957
+ ).fetchone()
958
+ is None
959
+ ):
960
+ report.checks.append(
961
+ CheckResult("runtime.recent_errors", SEV_PASS, "log_events not present (pre-v32)")
962
+ )
963
+ conn.close()
964
+ return
965
+ window_h = int(os.environ.get("COS_DOCTOR_ERROR_WINDOW_HOURS", "24"))
966
+ since = (datetime.now(timezone.utc) - timedelta(hours=window_h)).strftime(
967
+ "%Y-%m-%dT%H:%M:%SZ"
968
+ )
969
+ try:
970
+ from tools.logs import log_query
971
+ except ImportError:
972
+ from core.thinking_os.tools.logs import log_query
973
+ n_err = log_query(conn, level="error", since=since, limit=1)["total"]
974
+ n_fatal = log_query(conn, level="fatal", since=since, limit=1)["total"]
975
+ conn.close()
976
+ except Exception as exc:
977
+ report.checks.append(
978
+ CheckResult("runtime.recent_errors", SEV_WARN, f"could not read error store: {exc}")
979
+ )
980
+ return
981
+ threshold = int(os.environ.get("COS_DOCTOR_ERROR_THRESHOLD", "1"))
982
+ detail = {"errors": n_err, "fatal": n_fatal, "window_hours": window_h}
983
+ if n_fatal > 0:
984
+ report.checks.append(
985
+ CheckResult(
986
+ "runtime.recent_errors",
987
+ SEV_FAIL,
988
+ f"{n_fatal} FATAL + {n_err} ERROR in last {window_h}h — run `cos errors`",
989
+ detail,
990
+ )
991
+ )
992
+ elif n_err >= threshold:
993
+ report.checks.append(
994
+ CheckResult(
995
+ "runtime.recent_errors",
996
+ SEV_WARN,
997
+ f"{n_err} ERROR in last {window_h}h — run `cos errors`",
998
+ detail,
999
+ )
1000
+ )
1001
+ else:
1002
+ report.checks.append(
1003
+ CheckResult(
1004
+ "runtime.recent_errors", SEV_PASS, f"{n_err} errors in last {window_h}h", detail
1005
+ )
1006
+ )
1007
+
1008
+
1009
+ def _check_hub_code_fresh(report: DoctorReport) -> None:
1010
+ """hub.code_fresh — WARN when a running Hub serves core code older than disk (run `cos hub restart`)."""
1011
+ try:
1012
+ from cli.hub_commands import _hub_code_is_stale
1013
+ except Exception as exc:
1014
+ logger.debug("hub staleness check unavailable: %s", exc)
1015
+ report.checks.append(
1016
+ CheckResult("hub.code_fresh", SEV_PASS, "hub staleness check unavailable (skip)")
1017
+ )
1018
+ return
1019
+ stale, newest = _hub_code_is_stale()
1020
+ if stale:
1021
+ changed = newest.name if newest else "core code"
1022
+ report.checks.append(
1023
+ CheckResult(
1024
+ "hub.code_fresh",
1025
+ SEV_WARN,
1026
+ f"Hub serving stale code — {changed} changed after it started; run `cos hub restart`",
1027
+ {"newest_changed": str(newest) if newest else None},
1028
+ )
1029
+ )
1030
+ else:
1031
+ report.checks.append(CheckResult("hub.code_fresh", SEV_PASS, "hub fresh or not running"))
1032
+
1033
+
1034
+ def _check_module_consistency(project: Path, report: DoctorReport) -> None:
1035
+ """modules.state_consistency — .coding-os/disabled-hook-scripts matches subsystem state."""
1036
+ logger = logging.getLogger("coding_os.doctor")
1037
+ try:
1038
+ from cli.project_overrides import RUNTIME_ALLOWLIST, disabled_hook_scripts
1039
+
1040
+ expected = disabled_hook_scripts(project)
1041
+ allowlist_file = project / ".coding-os" / RUNTIME_ALLOWLIST
1042
+ if not allowlist_file.exists():
1043
+ if expected:
1044
+ report.checks.append(
1045
+ CheckResult(
1046
+ "modules.state_consistency",
1047
+ SEV_WARN,
1048
+ f"{len(expected)} hook(s) should be disabled but "
1049
+ ".coding-os/disabled-hook-scripts is missing — run `cos module disable <id>`",
1050
+ )
1051
+ )
1052
+ else:
1053
+ report.checks.append(
1054
+ CheckResult("modules.state_consistency", SEV_PASS, "no modules disabled")
1055
+ )
1056
+ return
1057
+ actual = {
1058
+ line.strip()
1059
+ for line in allowlist_file.read_text(encoding="utf-8").splitlines()
1060
+ if line.strip()
1061
+ }
1062
+ # Bidirectional: `missing` = under-disabled (a hook that should be off is
1063
+ # absent); `extra` = over-disabled (the allowlist lists hooks for a module
1064
+ # that is ENABLED — the inverted half-state a failed-toggle rollback leaves
1065
+ # behind). Checking only `missing` reported SEV_PASS on the over-disabled
1066
+ # corruption, certifying a desynced project as healthy. (audit pass-4 #10)
1067
+ missing = expected - actual
1068
+ extra = actual - expected
1069
+ if missing or extra:
1070
+ parts: list[str] = []
1071
+ if missing:
1072
+ parts.append(
1073
+ f"{len(missing)} expected hook(s) absent ({', '.join(sorted(missing)[:3])}…)"
1074
+ )
1075
+ if extra:
1076
+ parts.append(
1077
+ f"{len(extra)} hook(s) disabled for ENABLED module(s) "
1078
+ f"({', '.join(sorted(extra)[:3])}…) — over-disabled, likely a failed toggle rollback"
1079
+ )
1080
+ report.checks.append(
1081
+ CheckResult(
1082
+ "modules.state_consistency",
1083
+ SEV_WARN,
1084
+ "disabled-hook-scripts drift: "
1085
+ + "; ".join(parts)
1086
+ + " — regenerate via `cos module enable/disable <id>`",
1087
+ )
1088
+ )
1089
+ else:
1090
+ report.checks.append(
1091
+ CheckResult(
1092
+ "modules.state_consistency",
1093
+ SEV_PASS,
1094
+ f"allowlist matches module state ({len(expected)} disabled hook(s))",
1095
+ )
1096
+ )
1097
+ except Exception as exc:
1098
+ logger.debug("module consistency check skipped: %s", exc)
1099
+
1100
+
1101
+ def _check_module_skill_drift(project: Path, report: DoctorReport) -> None:
1102
+ """modules.skill_drift — a disabled module's owned skill is still linked.
1103
+
1104
+ The residue a `--keep-skills` disable (or an out-of-band edit) leaves: the
1105
+ module is off but its SKILL.md is still in an adapter skills dir. A skill
1106
+ also owned by an ENABLED module is never drift (ref-count)."""
1107
+ logger = logging.getLogger("coding_os.doctor")
1108
+ try:
1109
+ from cli.skill_commands import _installed_adapter_skills_dirs
1110
+ from cli.subsystems import load_subsystems, module_state
1111
+
1112
+ modules = load_subsystems()
1113
+ state = module_state(project, modules)
1114
+ enabled_owned = {
1115
+ skill
1116
+ for mid, module in modules.items()
1117
+ if state.get(mid, True)
1118
+ for skill in module.skills
1119
+ }
1120
+ skills_dirs = _installed_adapter_skills_dirs(project)
1121
+ drift: list[str] = []
1122
+ for mid, module in modules.items():
1123
+ if state.get(mid, True):
1124
+ continue
1125
+ for name in module.skills:
1126
+ if name in enabled_owned:
1127
+ continue
1128
+ if any(
1129
+ (d / name / "SKILL.md").exists() or (d / name).is_symlink() for d in skills_dirs
1130
+ ):
1131
+ drift.append(f"{name} (module '{mid}' off)")
1132
+ if drift:
1133
+ report.checks.append(
1134
+ CheckResult(
1135
+ "modules.skill_drift",
1136
+ SEV_WARN,
1137
+ f"{len(drift)} skill(s) linked for disabled module(s): "
1138
+ + ", ".join(sorted(set(drift))[:4])
1139
+ + " — `cos skill disable <name>` or re-run `cos module disable <id>`",
1140
+ )
1141
+ )
1142
+ else:
1143
+ report.checks.append(
1144
+ CheckResult("modules.skill_drift", SEV_PASS, "no module/skill drift")
1145
+ )
1146
+ except Exception as exc:
1147
+ logger.debug("module skill drift check skipped: %s", exc)
1148
+
1149
+
1150
+ def _check_module_command_drift(project: Path, report: DoctorReport) -> None:
1151
+ """modules.command_drift — a disabled module's owned slash-command is still
1152
+ linked in an adapter commands dir (TASK-481). A command also owned by an
1153
+ ENABLED module is never drift (ref-count)."""
1154
+ logger = logging.getLogger("coding_os.doctor")
1155
+ try:
1156
+ from cli.module_commands import _installed_adapter_commands_dirs
1157
+ from cli.subsystems import load_subsystems, module_state
1158
+
1159
+ modules = load_subsystems()
1160
+ state = module_state(project, modules)
1161
+ enabled_owned = {
1162
+ cmd
1163
+ for mid, module in modules.items()
1164
+ if state.get(mid, True)
1165
+ for cmd in module.commands
1166
+ }
1167
+ command_dirs = _installed_adapter_commands_dirs(project)
1168
+ drift: list[str] = []
1169
+ for mid, module in modules.items():
1170
+ if state.get(mid, True):
1171
+ continue
1172
+ for name in module.commands:
1173
+ if name in enabled_owned:
1174
+ continue
1175
+ cmd_file = f"{name}.md"
1176
+ if any(
1177
+ (d / cmd_file).exists() or (d / cmd_file).is_symlink() for d in command_dirs
1178
+ ):
1179
+ drift.append(f"{name} (module '{mid}' off)")
1180
+ if drift:
1181
+ report.checks.append(
1182
+ CheckResult(
1183
+ "modules.command_drift",
1184
+ SEV_WARN,
1185
+ f"{len(drift)} command(s) linked for disabled module(s): "
1186
+ + ", ".join(sorted(set(drift))[:4])
1187
+ + " — re-run `cos module disable <id>`",
1188
+ )
1189
+ )
1190
+ else:
1191
+ report.checks.append(
1192
+ CheckResult("modules.command_drift", SEV_PASS, "no module/command drift")
1193
+ )
1194
+ except Exception as exc:
1195
+ logger.debug("module command drift check skipped: %s", exc)
1196
+
1197
+
1198
+ def _check_module_rule_drift(project: Path, report: DoctorReport) -> None:
1199
+ """modules.rule_drift — a disabled module's owned core rule is still linked in
1200
+ an adapter rules dir (TASK-811). A rule also owned by an ENABLED module is
1201
+ never drift (ref-count)."""
1202
+ logger = logging.getLogger("coding_os.doctor")
1203
+ try:
1204
+ from cli.module_commands import _installed_adapter_rules_dirs
1205
+ from cli.subsystems import load_subsystems, module_state
1206
+
1207
+ modules = load_subsystems()
1208
+ state = module_state(project, modules)
1209
+ enabled_owned = {
1210
+ rule for mid, module in modules.items() if state.get(mid, True) for rule in module.rules
1211
+ }
1212
+ rules_dirs = _installed_adapter_rules_dirs(project)
1213
+ drift: list[str] = []
1214
+ for mid, module in modules.items():
1215
+ if state.get(mid, True):
1216
+ continue
1217
+ for name in module.rules:
1218
+ if name in enabled_owned:
1219
+ continue
1220
+ if any((d / name).exists() or (d / name).is_symlink() for d in rules_dirs):
1221
+ drift.append(f"{name} (module '{mid}' off)")
1222
+ if drift:
1223
+ report.checks.append(
1224
+ CheckResult(
1225
+ "modules.rule_drift",
1226
+ SEV_WARN,
1227
+ f"{len(drift)} rule(s) linked for disabled module(s): "
1228
+ + ", ".join(sorted(set(drift))[:4])
1229
+ + " — re-run `cos module disable <id>`",
1230
+ )
1231
+ )
1232
+ else:
1233
+ report.checks.append(
1234
+ CheckResult("modules.rule_drift", SEV_PASS, "no module/rule drift")
1235
+ )
1236
+ except Exception as exc:
1237
+ logger.debug("module rule drift check skipped: %s", exc)
1238
+
1239
+
1240
+ def _check_module_doc_drift(project: Path, report: DoctorReport) -> None:
1241
+ """modules.doc_drift — a disabled module's `| module:X`-tagged scaffold doc is
1242
+ still present in the consumer (TASK-813 backstop). The consumer copy has its
1243
+ tag stripped at init, so we map via the tagged scaffold SOURCE, not the
1244
+ untagged destination."""
1245
+ logger = logging.getLogger("coding_os.doctor")
1246
+ try:
1247
+ import yaml as _yaml
1248
+
1249
+ from cli.main import module_scaffold_doc_rels
1250
+ from cli.subsystems import load_subsystems, module_state
1251
+
1252
+ modules = load_subsystems()
1253
+ state = module_state(project, modules)
1254
+ disabled = [mid for mid in modules if not state.get(mid, True)]
1255
+ if not disabled:
1256
+ report.checks.append(CheckResult("modules.doc_drift", SEV_PASS, "no module/doc drift"))
1257
+ return
1258
+ try:
1259
+ config = (
1260
+ _yaml.safe_load((project / ".coding-os.yaml").read_text(encoding="utf-8")) or {}
1261
+ )
1262
+ templates = tuple(config.get("templates") or [])
1263
+ except (OSError, _yaml.YAMLError):
1264
+ templates = ()
1265
+ drift: list[str] = []
1266
+ for mid in disabled:
1267
+ for rel in module_scaffold_doc_rels(templates, mid):
1268
+ if (project / rel).is_file():
1269
+ drift.append(f"{rel} (module '{mid}' off)")
1270
+ if drift:
1271
+ report.checks.append(
1272
+ CheckResult(
1273
+ "modules.doc_drift",
1274
+ SEV_WARN,
1275
+ f"{len(drift)} doc(s) present for disabled module(s): "
1276
+ + ", ".join(sorted(set(drift))[:4])
1277
+ + " — `cos module disable <id>` re-prunes (backed up), or remove them",
1278
+ )
1279
+ )
1280
+ else:
1281
+ report.checks.append(CheckResult("modules.doc_drift", SEV_PASS, "no module/doc drift"))
1282
+ except Exception as exc:
1283
+ logger.debug("module doc drift check skipped: %s", exc)
1284
+
1285
+
1286
+ def _check_subsystems_state_integrity(project: Path, report: DoctorReport) -> None:
1287
+ """modules.state_integrity — a corrupt subsystems-state.json fails OPEN to
1288
+ all-enabled silently (TASK-474 P4-12); surface it as a WARN, not a false PASS."""
1289
+ logger = logging.getLogger("coding_os.doctor")
1290
+ try:
1291
+ from cli.subsystems import state_file_integrity
1292
+
1293
+ reason = state_file_integrity(project)
1294
+ if reason:
1295
+ report.checks.append(
1296
+ CheckResult(
1297
+ "modules.state_integrity",
1298
+ SEV_WARN,
1299
+ f"subsystems-state.json {reason} — module toggles silently fall back "
1300
+ "to ALL-ENABLED; fix or delete the file",
1301
+ )
1302
+ )
1303
+ else:
1304
+ report.checks.append(
1305
+ CheckResult("modules.state_integrity", SEV_PASS, "subsystems-state.json readable")
1306
+ )
1307
+ except Exception as exc:
1308
+ logger.debug("subsystems state integrity check skipped: %s", exc)
1309
+
1310
+
1311
+ def run_doctor(
1312
+ project: Path,
1313
+ *,
1314
+ manifest_path: Path | None = None,
1315
+ extra_ignores: list[str] | None = None,
1316
+ ) -> DoctorReport:
1317
+ """Run all implemented doctor checks and return a report."""
1318
+ report = DoctorReport(project_dir=str(project), agent=None, templates=[])
1319
+ config = _check_config(project, report)
1320
+ if config is None:
1321
+ return report
1322
+ state = _check_state_dir(project, config, report)
1323
+ graph_conn = None
1324
+ if state.is_dir():
1325
+ _tick("database + migrations")
1326
+ conn = _check_database(state, report)
1327
+ if conn is not None:
1328
+ with contextlib.closing(conn):
1329
+ pass
1330
+ _check_core_version(state, report)
1331
+ # Open a second short-lived connection for graph checks so
1332
+ # the first handle's contextlib.closing is not disturbed.
1333
+ try:
1334
+ import sqlite3 as _sqlite3
1335
+
1336
+ db_file = state / "coding-os.db"
1337
+ if db_file.exists():
1338
+ graph_conn = _sqlite3.connect(str(db_file))
1339
+ except Exception as exc:
1340
+ logger = logging.getLogger("coding_os.doctor")
1341
+ logger.debug("graph doctor connection failed: %s", exc)
1342
+ _check_scaffold_roots(project, report)
1343
+ _check_adapter(project, report.agent, report)
1344
+ _tick("scanning scaffold manifest")
1345
+ _check_manifest(project, report, manifest_path or MANIFEST_PATH_DEFAULT)
1346
+ _tick("scanning for placeholders")
1347
+ _check_placeholders(project, report)
1348
+ _tick("MCP self-test (up to 30s)")
1349
+ _check_mcp_selftest(project, report)
1350
+ _check_stack_registry_consistency(report)
1351
+ _check_category_balance(report)
1352
+ _tick("stack skills linkage")
1353
+ _check_stack_skills_linked(project, report)
1354
+ _tick("MCP portability")
1355
+ _check_mcp_portable(project, report)
1356
+ _tick("MCP launch handshake (up to 20s)")
1357
+ _check_mcp_actually_launches(project, report)
1358
+ _check_agents_md_present(project, report)
1359
+ _tick("cognition registries")
1360
+ _check_cognition_registries(project, report)
1361
+ _tick("hook coverage")
1362
+ _check_hook_coverage(project, report)
1363
+ _check_module_consistency(project, report)
1364
+ _check_module_skill_drift(project, report)
1365
+ _check_module_command_drift(project, report)
1366
+ _check_module_rule_drift(project, report)
1367
+ _check_module_doc_drift(project, report)
1368
+ _check_subsystems_state_integrity(project, report)
1369
+ _tick("runtime errors")
1370
+ _check_runtime_errors(state, report)
1371
+ # graph_os health checks.
1372
+ _tick("graph_os health")
1373
+ try:
1374
+ from cli.doctor_graph import run_graph_checks
1375
+
1376
+ # Module-aware (TASK-439): a project that disabled the graph module must
1377
+ # not be nagged to run graph-reindex on an intentionally-empty graph.
1378
+ from cli.subsystems import module_state
1379
+
1380
+ if module_state(project).get("graph", True):
1381
+ run_graph_checks(report, state, graph_conn)
1382
+ else:
1383
+ report.checks.append(
1384
+ CheckResult(
1385
+ "graph.module", SEV_PASS, "graph module disabled — skipping graph health"
1386
+ )
1387
+ )
1388
+ except ImportError as exc:
1389
+ logger = logging.getLogger("coding_os.doctor")
1390
+ logger.debug("graph doctor unavailable: %s", exc)
1391
+ finally:
1392
+ if graph_conn is not None:
1393
+ try:
1394
+ graph_conn.close()
1395
+ except Exception as exc:
1396
+ logger = logging.getLogger("coding_os.doctor")
1397
+ logger.debug("graph_conn close suppressed: %s", exc)
1398
+ # board_os health checks.
1399
+ _tick("board_os health")
1400
+ try:
1401
+ from cli.doctor_board import run_board_checks
1402
+
1403
+ run_board_checks(report, project, state)
1404
+ except ImportError as exc:
1405
+ logger = logging.getLogger("coding_os.doctor")
1406
+ logger.debug("board doctor unavailable: %s", exc)
1407
+ _check_scheduled(project, report)
1408
+ _check_presence_zombies(project, report)
1409
+ _tick("hub code freshness")
1410
+ _check_hub_code_fresh(report)
1411
+ try:
1412
+ from cli.doctor_extras import run_extra_checks
1413
+
1414
+ run_extra_checks(project, report)
1415
+ except ImportError as exc:
1416
+ logger = logging.getLogger("coding_os.doctor")
1417
+ logger.debug("doctor_extras unavailable: %s", exc)
1418
+ ignore_globs = _ignore_globs_from_config(config)
1419
+ if extra_ignores:
1420
+ ignore_globs.extend(extra_ignores)
1421
+ suppressed = _suppress_checks(report, ignore_globs)
1422
+ if suppressed > 0:
1423
+ report.suppressed = suppressed
1424
+ report.suppressed_globs = ignore_globs
1425
+ return report
1426
+
1427
+
1428
+ def _check_stack_registry_consistency(report: DoctorReport) -> None:
1429
+ """stack.registry_valid — every stack declared in .coding-os.yaml::templates exists in the registry.
1430
+
1431
+ If a stack was installed and later removed from the coding-os distribution,
1432
+ the project config still lists it — FAIL so the user knows to either add
1433
+ the stack back or remove it from their config.
1434
+ """
1435
+ try:
1436
+ from cli.stack_registry import load_stack_registry
1437
+
1438
+ registry = load_stack_registry(templates_dir())
1439
+ except Exception as exc:
1440
+ report.checks.append(
1441
+ CheckResult(
1442
+ "stack.registry_valid",
1443
+ SEV_WARN,
1444
+ f"could not load stack registry: {exc}",
1445
+ )
1446
+ )
1447
+ return
1448
+
1449
+ missing = [t for t in report.templates if t not in registry]
1450
+ if missing:
1451
+ report.checks.append(
1452
+ CheckResult(
1453
+ "stack.registry_valid",
1454
+ SEV_FAIL,
1455
+ f"stacks in config not found in templates/: {', '.join(missing)}",
1456
+ {"missing": missing},
1457
+ )
1458
+ )
1459
+ elif not report.templates:
1460
+ report.checks.append(
1461
+ CheckResult(
1462
+ "stack.registry_valid",
1463
+ SEV_PASS,
1464
+ "no stacks installed (base-only project)",
1465
+ )
1466
+ )
1467
+ else:
1468
+ report.checks.append(
1469
+ CheckResult(
1470
+ "stack.registry_valid",
1471
+ SEV_PASS,
1472
+ f"all {len(report.templates)} installed stack(s) present in registry",
1473
+ {"installed": report.templates},
1474
+ )
1475
+ )
1476
+
1477
+
1478
+ def _check_category_balance(report: DoctorReport) -> None:
1479
+ """stack.category_balance — informational WARN when two or more stacks of the same category
1480
+ are installed (e.g. two backend stacks). The project will work, but the
1481
+ later stack wins on conflicting substitution keys — the user should know."""
1482
+ if len(report.templates) < 2:
1483
+ report.checks.append(
1484
+ CheckResult(
1485
+ "stack.category_balance",
1486
+ SEV_PASS,
1487
+ "single-stack or base-only project",
1488
+ )
1489
+ )
1490
+ return
1491
+
1492
+ try:
1493
+ from cli.stack_registry import load_stack_registry
1494
+
1495
+ registry = load_stack_registry(templates_dir())
1496
+ except Exception:
1497
+ report.checks.append(
1498
+ CheckResult(
1499
+ "stack.category_balance",
1500
+ SEV_PASS,
1501
+ "registry unavailable, skipping",
1502
+ )
1503
+ )
1504
+ return
1505
+
1506
+ categories: dict[str, list[str]] = {}
1507
+ for stack_id in report.templates:
1508
+ if stack_id in registry:
1509
+ cat = registry[stack_id].category
1510
+ categories.setdefault(cat, []).append(stack_id)
1511
+
1512
+ duplicates = {c: ids for c, ids in categories.items() if len(ids) >= 2}
1513
+ if duplicates:
1514
+ details = ", ".join(f"{cat}: {', '.join(ids)}" for cat, ids in duplicates.items())
1515
+ report.checks.append(
1516
+ CheckResult(
1517
+ "stack.category_balance",
1518
+ SEV_WARN,
1519
+ f"multiple stacks in same category ({details}) — last stack wins on conflicts",
1520
+ {"duplicates": duplicates},
1521
+ )
1522
+ )
1523
+ else:
1524
+ report.checks.append(
1525
+ CheckResult(
1526
+ "stack.category_balance",
1527
+ SEV_PASS,
1528
+ f"{len(report.templates)} stacks in {len(categories)} distinct categories",
1529
+ )
1530
+ )
1531
+
1532
+
1533
+ def _check_stack_skills_linked(project: Path, report: DoctorReport) -> None:
1534
+ """stack.skills_linked — every installed stack's skills are symlinked into the agent's skills dir.
1535
+
1536
+ Detects the B1 regression where `.claude/skills/python-django/SKILL.md`
1537
+ was missing even though `--template django` was declared. We consult the
1538
+ adapter registry to find `skills_dir` (null for Codex → skip check) and
1539
+ the src/templates/<stack>/skills/ source of truth.
1540
+ """
1541
+ if not report.templates:
1542
+ report.checks.append(CheckResult("stack.skills_linked", SEV_PASS, "no stacks installed"))
1543
+ return
1544
+ if not report.agent:
1545
+ report.checks.append(CheckResult("stack.skills_linked", SEV_PASS, "no agent configured"))
1546
+ return
1547
+ try:
1548
+ from cli.adapter_registry import load_adapter_registry
1549
+
1550
+ adapters = load_adapter_registry(adapters_dir())
1551
+ except Exception as exc:
1552
+ report.checks.append(
1553
+ CheckResult(
1554
+ "stack.skills_linked",
1555
+ SEV_WARN,
1556
+ f"could not load adapter registry: {exc}",
1557
+ )
1558
+ )
1559
+ return
1560
+ profile = adapters.get(report.agent)
1561
+ if profile is None or not profile.skills_dir:
1562
+ report.checks.append(
1563
+ CheckResult(
1564
+ "stack.skills_linked",
1565
+ SEV_PASS,
1566
+ f"adapter '{report.agent}' has no skills_dir — skipped",
1567
+ )
1568
+ )
1569
+ return
1570
+
1571
+ skills_dir = project / profile.skills_dir
1572
+ expected: list[tuple[str, str]] = [] # (stack, skill_name)
1573
+ for stack in report.templates:
1574
+ stack_skills = templates_dir(stack, "skills")
1575
+ if not stack_skills.exists():
1576
+ continue
1577
+ for entry in stack_skills.iterdir():
1578
+ if entry.is_dir() and (entry / "SKILL.md").exists():
1579
+ expected.append((stack, entry.name))
1580
+
1581
+ if not expected:
1582
+ report.checks.append(
1583
+ CheckResult(
1584
+ "stack.skills_linked",
1585
+ SEV_PASS,
1586
+ "no stack skills to link",
1587
+ )
1588
+ )
1589
+ return
1590
+
1591
+ missing = []
1592
+ for stack, name in expected:
1593
+ link = skills_dir / name / "SKILL.md"
1594
+ if not link.exists():
1595
+ missing.append(f"{stack}:{name}")
1596
+
1597
+ if missing:
1598
+ report.checks.append(
1599
+ CheckResult(
1600
+ "stack.skills_linked",
1601
+ SEV_FAIL,
1602
+ f"missing stack skill links: {', '.join(missing)} — run `cos update` to repair",
1603
+ {"missing": missing},
1604
+ )
1605
+ )
1606
+ else:
1607
+ report.checks.append(
1608
+ CheckResult(
1609
+ "stack.skills_linked",
1610
+ SEV_PASS,
1611
+ f"all {len(expected)} stack skill(s) linked",
1612
+ )
1613
+ )
1614
+
1615
+
1616
+ def _check_mcp_portable(project: Path, report: DoctorReport) -> None:
1617
+ """mcp.portable — .mcp.json coding-os entry uses the `cos server-start` wrapper.
1618
+
1619
+ The wrapper form lets the project survive coding-os relocations and
1620
+ upgrades: the `cos` binary on PATH resolves the server location, no
1621
+ absolute dev path is hardcoded. A plain `uv run --directory <abs>`
1622
+ entry is tolerated as a bootstrap fallback but flagged WARN.
1623
+ """
1624
+ mcp_path = project / ".mcp.json"
1625
+ if not mcp_path.exists():
1626
+ report.checks.append(CheckResult("mcp.portable", SEV_PASS, "no .mcp.json (skip)"))
1627
+ return
1628
+ try:
1629
+ import json as _json
1630
+
1631
+ data = _json.loads(mcp_path.read_text(encoding="utf-8"))
1632
+ except Exception as exc:
1633
+ report.checks.append(CheckResult("mcp.portable", SEV_FAIL, f"invalid JSON: {exc}"))
1634
+ return
1635
+ entry = (data.get("mcpServers") or {}).get("coding-os")
1636
+ if entry is None:
1637
+ report.checks.append(
1638
+ CheckResult(
1639
+ "mcp.portable",
1640
+ SEV_PASS,
1641
+ "no coding-os MCP entry (skip)",
1642
+ )
1643
+ )
1644
+ return
1645
+ command = entry.get("command")
1646
+ if command == "cos":
1647
+ report.checks.append(
1648
+ CheckResult(
1649
+ "mcp.portable",
1650
+ SEV_PASS,
1651
+ "uses `cos server-start` wrapper (portable)",
1652
+ )
1653
+ )
1654
+ return
1655
+ args = entry.get("args") or []
1656
+ has_abs_cos_path = any(isinstance(a, str) and "/core/thinking_os" in a for a in args)
1657
+ if has_abs_cos_path:
1658
+ report.checks.append(
1659
+ CheckResult(
1660
+ "mcp.portable",
1661
+ SEV_WARN,
1662
+ "hardcoded absolute path — runs fine locally but won't "
1663
+ "survive coding-os relocation. Install `cos` on PATH and "
1664
+ "re-run the adapter install to switch to the wrapper.",
1665
+ )
1666
+ )
1667
+ else:
1668
+ report.checks.append(
1669
+ CheckResult(
1670
+ "mcp.portable",
1671
+ SEV_PASS,
1672
+ f"unknown command form '{command}' — assumed portable",
1673
+ )
1674
+ )
1675
+
1676
+
1677
+ def _load_coding_os_mcp_launch(
1678
+ project: Path,
1679
+ agent: str | None,
1680
+ ) -> tuple[str | None, list[str], dict[str, str], str | None, str | None]:
1681
+ """Return the coding-os MCP launch config from any adapter (Claude/Codex/Cursor)."""
1682
+
1683
+ def _load_claude_json(
1684
+ path: Path,
1685
+ ) -> tuple[str | None, list[str], dict[str, str], str | None, str | None] | None:
1686
+ if not path.exists():
1687
+ return None
1688
+ try:
1689
+ import json as _json
1690
+
1691
+ data = _json.loads(path.read_text(encoding="utf-8"))
1692
+ except Exception as exc:
1693
+ return None, [], {}, str(path), f"invalid JSON: {exc}"
1694
+ entry = (data.get("mcpServers") or {}).get("coding-os")
1695
+ if entry is None:
1696
+ return None, [], {}, str(path), None
1697
+ env = {str(k): str(v) for k, v in (entry.get("env") or {}).items()}
1698
+ return entry.get("command"), list(entry.get("args") or []), env, str(path), None
1699
+
1700
+ def _load_codex_toml(path: Path) -> tuple[str | None, list[str], dict[str, str]] | None:
1701
+ if not path.exists():
1702
+ return None
1703
+ text = path.read_text(encoding="utf-8")
1704
+ match = re.search(r"(?ms)^\[mcp_servers\.coding-os\]\s*\n(?P<body>.*?)(?=^\[|\Z)", text)
1705
+ if not match:
1706
+ return None
1707
+ body = match.group("body")
1708
+ cmd_match = re.search(r'(?m)^[ \t]*command[ \t]*=[ \t]*"([^"]+)"[ \t]*$', body)
1709
+ if not cmd_match:
1710
+ return "", [], {}
1711
+ args_match = re.search(r"(?ms)^[ \t]*args[ \t]*=[ \t]*\[(.*?)\][ \t]*$", body)
1712
+ args = []
1713
+ if args_match:
1714
+ args = re.findall(r'"((?:[^"\\]|\\.)*)"', args_match.group(1))
1715
+ args = [bytes(item, "utf-8").decode("unicode_escape") for item in args]
1716
+ env: dict[str, str] = {}
1717
+ env_match = re.search(r"(?ms)^[ \t]*env[ \t]*=[ \t]*\{(.*?)\}[ \t]*$", body)
1718
+ if env_match:
1719
+ for key, value in re.findall(
1720
+ r'"((?:[^"\\]|\\.)*)"[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"', env_match.group(1)
1721
+ ):
1722
+ env[bytes(key, "utf-8").decode("unicode_escape")] = bytes(value, "utf-8").decode(
1723
+ "unicode_escape"
1724
+ )
1725
+ return cmd_match.group(1), args, env
1726
+
1727
+ def _load_codex(
1728
+ path: Path,
1729
+ ) -> tuple[str | None, list[str], dict[str, str], str | None, str | None] | None:
1730
+ loaded = _load_codex_toml(path)
1731
+ if loaded is None:
1732
+ return None
1733
+ command, args, env = loaded
1734
+ return command, args, env, str(path), None
1735
+
1736
+ # Registry-driven loader selection — each adapter declares its
1737
+ # mcp_launch.loader and config_paths in adapter.yaml so no agent id
1738
+ # is hardcoded here (Rule 12 / tests/test_no_hardcoded_stacks).
1739
+ from cli.adapter_registry import load_adapter_registry
1740
+
1741
+ adapters = load_adapter_registry(adapters_dir())
1742
+
1743
+ loader_fns = {
1744
+ "claude_json": _load_claude_json,
1745
+ "codex_toml": _load_codex,
1746
+ # Cursor's .cursor/mcp.json uses the same mcpServers.coding-os JSON
1747
+ # shape as Claude (see src/adapters/cursor/install.sh), so it reuses
1748
+ # the Claude JSON loader. Without this entry the Cursor MCP launch
1749
+ # diagnostic was silently skipped (spec.loader not in loader_fns).
1750
+ "cursor_mcp_json": _load_claude_json,
1751
+ }
1752
+
1753
+ loaders: list[tuple[str, Path]] = []
1754
+ for aid, profile in adapters.items():
1755
+ if agent and agent != aid:
1756
+ continue
1757
+ spec = profile.mcp_launch
1758
+ if spec is None:
1759
+ continue
1760
+ if spec.loader not in loader_fns:
1761
+ continue
1762
+ for cp in spec.config_paths:
1763
+ root = project if cp.scope == "project" else Path.home()
1764
+ loaders.append((spec.loader, root / cp.path))
1765
+
1766
+ for loader_name, path in loaders:
1767
+ fn = loader_fns.get(loader_name)
1768
+ if fn is None:
1769
+ continue
1770
+ loaded = fn(path)
1771
+ if loaded is not None:
1772
+ return loaded
1773
+
1774
+ return None, [], {}, None, None
1775
+
1776
+
1777
+ def _check_mcp_actually_launches(project: Path, report: DoctorReport) -> None:
1778
+ """mcp.actually_launches — simulate the exact MCP launch path the active agent config uses.
1779
+
1780
+ mcp.self_test_passes runs `server.py --test` with an explicit COS_DB_PATH env — that
1781
+ verifies the server code works but bypasses the agent launch config
1782
+ entirely. mcp.actually_launches closes that gap: it reads coding-os MCP launch config
1783
+ from Claude or Codex, runs the declared command with the project
1784
+ root as cwd, feeds a real `initialize` handshake, and expects a
1785
+ valid JSON-RPC response.
1786
+ """
1787
+ command, args, entry_env, source_path, load_error = _load_coding_os_mcp_launch(
1788
+ project, report.agent
1789
+ )
1790
+ if load_error:
1791
+ report.checks.append(CheckResult("mcp.actually_launches", SEV_FAIL, load_error))
1792
+ return
1793
+ if source_path is None:
1794
+ # Data-driven (Rule 11): list every adapter that ships an
1795
+ # install.sh under src/adapters/<id>/. New adapters appear here
1796
+ # automatically — no edit to this diagnostic when one is added.
1797
+ meta_root = Path(__file__).resolve().parent.parent.parent / "src" / "adapters"
1798
+ adapter_lines: list[str] = []
1799
+ if meta_root.is_dir():
1800
+ for adapter_yaml in sorted(meta_root.glob("*/adapter.yaml")):
1801
+ install_sh = adapter_yaml.parent / "install.sh"
1802
+ if install_sh.exists():
1803
+ adapter_lines.append(
1804
+ f"`bash <coding-os>/adapters/{adapter_yaml.parent.name}/install.sh`"
1805
+ )
1806
+ if adapter_lines:
1807
+ repair = "Run " + " or ".join(adapter_lines) + " from the project root."
1808
+ else:
1809
+ repair = (
1810
+ "Run `bash <coding-os>/adapters/<adapter>/install.sh` for the "
1811
+ "adapter you use, from the project root."
1812
+ )
1813
+ report.checks.append(
1814
+ CheckResult(
1815
+ "mcp.actually_launches",
1816
+ SEV_FAIL,
1817
+ "coding-os MCP config missing — neither .mcp.json nor "
1818
+ ".codex/config.toml defines coding-os. " + repair,
1819
+ )
1820
+ )
1821
+ return
1822
+ if command is None:
1823
+ report.checks.append(
1824
+ CheckResult(
1825
+ "mcp.actually_launches",
1826
+ SEV_PASS,
1827
+ f"no coding-os MCP entry in {source_path} (skip)",
1828
+ )
1829
+ )
1830
+ return
1831
+
1832
+ env = os.environ.copy()
1833
+ env.update(entry_env)
1834
+
1835
+ handshake = (
1836
+ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":'
1837
+ '{"protocolVersion":"2025-03-26","capabilities":{},'
1838
+ '"clientInfo":{"name":"cos-doctor","version":"1.0"}}}\n'
1839
+ )
1840
+
1841
+ if not command:
1842
+ report.checks.append(
1843
+ CheckResult(
1844
+ "mcp.actually_launches",
1845
+ SEV_FAIL,
1846
+ f"no command specified in {source_path}",
1847
+ )
1848
+ )
1849
+ return
1850
+
1851
+ try:
1852
+ proc = subprocess.run(
1853
+ [command, *args],
1854
+ input=handshake,
1855
+ cwd=str(project),
1856
+ env=env,
1857
+ capture_output=True,
1858
+ text=True,
1859
+ timeout=20,
1860
+ check=False,
1861
+ )
1862
+ except FileNotFoundError:
1863
+ report.checks.append(
1864
+ CheckResult(
1865
+ "mcp.actually_launches",
1866
+ SEV_FAIL,
1867
+ f"command not found on PATH: {command!r}. "
1868
+ f"Install via `uv tool install --editable <coding-os>`.",
1869
+ )
1870
+ )
1871
+ return
1872
+ except subprocess.TimeoutExpired:
1873
+ report.checks.append(
1874
+ CheckResult(
1875
+ "mcp.actually_launches",
1876
+ SEV_PASS,
1877
+ "launched (exceeded 20s → server is running, no crash)",
1878
+ )
1879
+ )
1880
+ return
1881
+ except OSError as exc:
1882
+ report.checks.append(
1883
+ CheckResult(
1884
+ "mcp.actually_launches",
1885
+ SEV_FAIL,
1886
+ f"OS error launching: {exc}",
1887
+ )
1888
+ )
1889
+ return
1890
+
1891
+ combined = (proc.stdout or "") + "\n" + (proc.stderr or "")
1892
+ if '"jsonrpc"' in (proc.stdout or "") and '"result"' in (proc.stdout or ""):
1893
+ report.checks.append(
1894
+ CheckResult(
1895
+ "mcp.actually_launches",
1896
+ SEV_PASS,
1897
+ "initialize handshake succeeded (server ready)",
1898
+ )
1899
+ )
1900
+ return
1901
+
1902
+ if "unable to open database file" in combined or "OperationalError" in combined:
1903
+ msg = (
1904
+ "server crashed: cannot open DB. This usually means the "
1905
+ "MCP launch config uses `uv run --directory ...` which "
1906
+ "chdir's into the server tree, so `.coding-os/coding-os.db` "
1907
+ "stops resolving. Switch to the wrapper form: "
1908
+ '`command = "cos"` and `args = ["server-start"]`.'
1909
+ )
1910
+ elif "No module named" in combined or "ModuleNotFoundError" in combined:
1911
+ msg = "server crashed: missing Python dependency — rerun `uv sync`."
1912
+ else:
1913
+ tail = combined.strip().splitlines()[-3:]
1914
+ msg = f"launch failed (exit {proc.returncode}). Last output: " + " | ".join(tail)[-200:]
1915
+
1916
+ report.checks.append(
1917
+ CheckResult(
1918
+ "mcp.actually_launches",
1919
+ SEV_FAIL,
1920
+ msg,
1921
+ {"stderr_tail": (proc.stderr or "")[-500:]},
1922
+ )
1923
+ )
1924
+
1925
+
1926
+ def _check_agents_md_present(project: Path, report: DoctorReport) -> None:
1927
+ """docs.agents_md_present — AGENTS.md at the project root is the canonical instruction file.
1928
+
1929
+ Read by both Claude (via AGENTS.md convention) and Codex. `cos init`
1930
+ generates it; pre-v0.2.0 projects or partial installs may be missing it.
1931
+ `cos add-adapter` and `cos update` now backfill automatically — this
1932
+ check catches projects that never ran either command since.
1933
+ """
1934
+ agents_md = project / "AGENTS.md"
1935
+ if agents_md.exists():
1936
+ report.checks.append(
1937
+ CheckResult(
1938
+ "docs.agents_md_present",
1939
+ SEV_PASS,
1940
+ "present",
1941
+ {"path": str(agents_md.relative_to(project))},
1942
+ )
1943
+ )
1944
+ return
1945
+ report.checks.append(
1946
+ CheckResult(
1947
+ "docs.agents_md_present",
1948
+ SEV_FAIL,
1949
+ "missing — run 'cos update' or 'cos add-adapter <agent>' to backfill",
1950
+ {"expected": "AGENTS.md"},
1951
+ )
1952
+ )
1953
+
1954
+
1955
+ def _check_cognition_registries(project: Path, report: DoctorReport) -> None:
1956
+ """cognition.registries_present — Cognition registries valid.
1957
+
1958
+ - roles/F{1..11}_*.yaml all exist with id + activation + prompt_prefix
1959
+ - presets/registry.yaml parses and has ≥8 curated presets
1960
+ - situations/registry.yaml parses and has ≥6 situations
1961
+ - agents/F{1..11}_*.md all exist with valid YAML frontmatter
1962
+ """
1963
+ import re as _re
1964
+
1965
+ thinking_os = project / "src" / "core" / "thinking_os"
1966
+ if not thinking_os.is_dir():
1967
+ report.checks.append(
1968
+ CheckResult("cognition.registries_present", SEV_PASS, "no thinking_os/ (skip)")
1969
+ )
1970
+ return
1971
+
1972
+ issues: list[str] = []
1973
+ warnings: list[str] = []
1974
+
1975
+ _EXPECTED_ROLES = [
1976
+ "researcher",
1977
+ "analyst",
1978
+ "architect",
1979
+ "documenter",
1980
+ "implementer",
1981
+ "reviewer",
1982
+ "debugger",
1983
+ "security_auditor",
1984
+ "deployer",
1985
+ "observer",
1986
+ "refactorer",
1987
+ ]
1988
+
1989
+ # Role registry (primary, semantic names)
1990
+ roles_dir = thinking_os / "roles"
1991
+ if not roles_dir.is_dir():
1992
+ issues.append("roles/ directory missing")
1993
+ else:
1994
+ for role in _EXPECTED_ROLES:
1995
+ yaml_file = roles_dir / f"{role}.yaml"
1996
+ if not yaml_file.exists():
1997
+ issues.append(f"roles/{role}.yaml missing")
1998
+ continue
1999
+ try:
2000
+ import yaml as _yaml
2001
+
2002
+ data = _yaml.safe_load(yaml_file.read_text()) or {}
2003
+ if data.get("id") != role:
2004
+ issues.append(f"{yaml_file.name}: id mismatch (expected {role})")
2005
+ for required in (
2006
+ "activation",
2007
+ "prompt_prefix",
2008
+ "criteria_required",
2009
+ "intensity_steps",
2010
+ ):
2011
+ if required not in data:
2012
+ issues.append(f"{yaml_file.name}: missing '{required}'")
2013
+ except Exception as exc:
2014
+ issues.append(f"{yaml_file.name}: invalid YAML: {exc}")
2015
+
2016
+ # Preset registry
2017
+ preset_reg = thinking_os / "presets" / "registry.yaml"
2018
+ if not preset_reg.exists():
2019
+ issues.append("presets/registry.yaml missing")
2020
+ else:
2021
+ try:
2022
+ import yaml as _yaml
2023
+
2024
+ data = _yaml.safe_load(preset_reg.read_text()) or {}
2025
+ presets = data.get("presets", []) if isinstance(data, dict) else []
2026
+ count = len(presets) if isinstance(presets, list) else 0
2027
+ if count < 8:
2028
+ issues.append(f"presets/registry.yaml has {count} presets (need ≥8)")
2029
+ else:
2030
+ # Validate preset shape
2031
+ for preset in presets:
2032
+ if "id" not in preset or "match" not in preset or "score" not in preset:
2033
+ issues.append(f"preset malformed: {preset.get('id', '?')}")
2034
+ break
2035
+ except Exception as exc:
2036
+ issues.append(f"presets/registry.yaml invalid YAML: {exc}")
2037
+
2038
+ # Situation registry
2039
+ situation_reg = thinking_os / "situations" / "registry.yaml"
2040
+ if not situation_reg.exists():
2041
+ issues.append("situations/registry.yaml missing")
2042
+ else:
2043
+ try:
2044
+ import yaml as _yaml
2045
+
2046
+ data = _yaml.safe_load(situation_reg.read_text()) or {}
2047
+ situations = data.get("situations", []) if isinstance(data, dict) else []
2048
+ count = len(situations) if isinstance(situations, list) else 0
2049
+ if count < 6:
2050
+ issues.append(f"situations/registry.yaml has {count} situations (need ≥6)")
2051
+ except Exception as exc:
2052
+ issues.append(f"situations/registry.yaml invalid YAML: {exc}")
2053
+
2054
+ # Formula-agent files (semantic names — one file per role; reuses _EXPECTED_ROLES above)
2055
+ agents_dir = thinking_os / "agents"
2056
+ _ROLE_ID_RE = _re.compile(r"^id:\s*(\w+)", _re.MULTILINE)
2057
+ for role in _EXPECTED_ROLES:
2058
+ agent_file = agents_dir / f"{role}.md"
2059
+ if not agent_file.exists():
2060
+ issues.append(f"agents/{role}.md missing")
2061
+ continue
2062
+ content = agent_file.read_text(encoding="utf-8")
2063
+ if not content.startswith("---"):
2064
+ issues.append(f"{agent_file.name}: missing YAML frontmatter")
2065
+ else:
2066
+ m = _ROLE_ID_RE.search(content)
2067
+ if not m or m.group(1) != role:
2068
+ issues.append(f"{agent_file.name}: missing or wrong 'id: {role}' in frontmatter")
2069
+
2070
+ if issues:
2071
+ report.checks.append(
2072
+ CheckResult(
2073
+ "cognition.registries_present",
2074
+ SEV_FAIL,
2075
+ "; ".join(issues),
2076
+ {"issues": issues, "warnings": warnings},
2077
+ )
2078
+ )
2079
+ elif warnings:
2080
+ report.checks.append(
2081
+ CheckResult(
2082
+ "cognition.registries_present",
2083
+ SEV_WARN,
2084
+ f"Roles/presets/situations OK (11 roles, 12+ presets, 6 situations, 11 agents); {'; '.join(warnings)}",
2085
+ )
2086
+ )
2087
+ else:
2088
+ report.checks.append(
2089
+ CheckResult(
2090
+ "cognition.registries_present",
2091
+ SEV_PASS,
2092
+ "Cognition registries: 11 roles, 12+ presets, 6 situations, 11 formula-agents — all valid",
2093
+ )
2094
+ )
2095
+
2096
+
2097
+ def _check_hook_coverage(project: Path, report: DoctorReport) -> None:
2098
+ """hook.coverage — every hook script in registry.yaml has an executable on disk
2099
+ AND each declared event/matcher pair is renderable for at least one
2100
+ adapter that lists the matching capability. Closes drift between
2101
+ registry.yaml (SSOT) and the rendered adapter templates.
2102
+ """
2103
+ registry_path = project / "src" / "core" / "hooks" / "registry.yaml"
2104
+ hooks_dir = project / "src" / "core" / "hooks"
2105
+ adapters_dir = project / "src" / "adapters"
2106
+
2107
+ if not registry_path.exists() or not hooks_dir.is_dir():
2108
+ report.checks.append(
2109
+ CheckResult(
2110
+ "hook.coverage",
2111
+ SEV_PASS,
2112
+ "no registry.yaml (skip)",
2113
+ )
2114
+ )
2115
+ return
2116
+
2117
+ try:
2118
+ import yaml as _yaml
2119
+
2120
+ registry = _yaml.safe_load(registry_path.read_text()) or {}
2121
+ except Exception as exc:
2122
+ report.checks.append(
2123
+ CheckResult(
2124
+ "hook.coverage",
2125
+ SEV_FAIL,
2126
+ f"registry.yaml invalid YAML: {exc}",
2127
+ )
2128
+ )
2129
+ return
2130
+
2131
+ hooks = registry.get("hooks", []) if isinstance(registry, dict) else []
2132
+ if not isinstance(hooks, list) or not hooks:
2133
+ report.checks.append(
2134
+ CheckResult(
2135
+ "hook.coverage",
2136
+ SEV_FAIL,
2137
+ "registry.yaml has no hooks list",
2138
+ )
2139
+ )
2140
+ return
2141
+
2142
+ adapter_caps: list[tuple[str, dict[str, list[str]]]] = []
2143
+ if adapters_dir.is_dir():
2144
+ try:
2145
+ import yaml as _yaml
2146
+
2147
+ for adapter_yaml in sorted(adapters_dir.glob("*/adapter.yaml")):
2148
+ try:
2149
+ data = _yaml.safe_load(adapter_yaml.read_text()) or {}
2150
+ except Exception:
2151
+ continue
2152
+ raw = data.get("hook_capabilities") or data.get("capabilities") or {}
2153
+ normalized: dict[str, list[str]] = {}
2154
+ if isinstance(raw, dict):
2155
+ for ev, spec in raw.items():
2156
+ if isinstance(spec, dict):
2157
+ matchers = spec.get("matchers") or spec.get("matcher") or [""]
2158
+ else:
2159
+ matchers = spec
2160
+ if isinstance(matchers, str):
2161
+ normalized[str(ev)] = [matchers]
2162
+ elif isinstance(matchers, list):
2163
+ normalized[str(ev)] = [str(m) for m in matchers]
2164
+ elif isinstance(raw, list):
2165
+ for cap in raw:
2166
+ if not isinstance(cap, dict):
2167
+ continue
2168
+ ev = str(cap.get("event") or "")
2169
+ if not ev:
2170
+ continue
2171
+ matchers = cap.get("matchers") or cap.get("matcher") or [""]
2172
+ if isinstance(matchers, str):
2173
+ normalized.setdefault(ev, []).append(matchers)
2174
+ elif isinstance(matchers, list):
2175
+ normalized.setdefault(ev, []).extend(str(m) for m in matchers)
2176
+ if normalized:
2177
+ adapter_caps.append((adapter_yaml.parent.name, normalized))
2178
+ except Exception as exc:
2179
+ logger = logging.getLogger("coding_os.doctor")
2180
+ logger.debug("adapter scan failed: %s", exc)
2181
+
2182
+ def _pair_renderable(event: str, matcher: str) -> list[str]:
2183
+ out: list[str] = []
2184
+ for name, caps in adapter_caps:
2185
+ matcher_list = caps.get(event)
2186
+ if matcher_list is None:
2187
+ continue
2188
+ if matcher == "":
2189
+ if "" in matcher_list or matcher_list == []:
2190
+ out.append(name)
2191
+ continue
2192
+ if matcher in matcher_list:
2193
+ out.append(name)
2194
+ continue
2195
+ wanted = set(matcher.split("|")) if matcher else set()
2196
+ for cand in matcher_list:
2197
+ if not cand:
2198
+ continue
2199
+ cand_set = set(cand.split("|"))
2200
+ if wanted and wanted.issubset(cand_set):
2201
+ out.append(name)
2202
+ break
2203
+ if cand_set & wanted:
2204
+ out.append(name)
2205
+ break
2206
+ return out
2207
+
2208
+ missing_scripts: list[str] = []
2209
+ non_executable: list[str] = []
2210
+ orphan_pairs: list[str] = []
2211
+ total_hooks = 0
2212
+ total_pairs = 0
2213
+
2214
+ for entry in hooks:
2215
+ if not isinstance(entry, dict):
2216
+ continue
2217
+ total_hooks += 1
2218
+ hook_id = entry.get("id") or "?"
2219
+ script = entry.get("script") or f"{hook_id}.sh"
2220
+ # adapter_scope hooks live under src/adapters/<scope>/hooks/, not core —
2221
+ # resolve there so a claude-only hook isn't falsely flagged missing.
2222
+ scope = entry.get("adapter_scope")
2223
+ script_path = (
2224
+ (adapters_dir / str(scope) / "hooks" / script) if scope else (hooks_dir / script)
2225
+ )
2226
+ if not script_path.exists():
2227
+ missing_scripts.append(f"{hook_id}: {script}")
2228
+ continue
2229
+ if not os.access(script_path, os.X_OK):
2230
+ non_executable.append(f"{hook_id}: {script}")
2231
+
2232
+ events = entry.get("events") or []
2233
+ if not isinstance(events, list):
2234
+ continue
2235
+ for ev in events:
2236
+ if not isinstance(ev, dict):
2237
+ continue
2238
+ total_pairs += 1
2239
+ event_name = str(ev.get("event") or "")
2240
+ matcher = str(ev.get("matcher") or "")
2241
+ if not event_name:
2242
+ orphan_pairs.append(f"{hook_id}: empty event")
2243
+ continue
2244
+ if adapter_caps and not _pair_renderable(event_name, matcher):
2245
+ orphan_pairs.append(f"{hook_id}: {event_name}/{matcher or '*'}")
2246
+
2247
+ detail = {
2248
+ "total_hooks": total_hooks,
2249
+ "total_pairs": total_pairs,
2250
+ "adapters_scanned": [name for name, _ in adapter_caps],
2251
+ "missing_scripts": missing_scripts,
2252
+ "non_executable": non_executable,
2253
+ "orphan_pairs": orphan_pairs[:10],
2254
+ }
2255
+
2256
+ if missing_scripts:
2257
+ report.checks.append(
2258
+ CheckResult(
2259
+ "hook.coverage",
2260
+ SEV_FAIL,
2261
+ f"{len(missing_scripts)} hook(s) missing script: " + "; ".join(missing_scripts[:5]),
2262
+ detail,
2263
+ )
2264
+ )
2265
+ return
2266
+ if non_executable:
2267
+ report.checks.append(
2268
+ CheckResult(
2269
+ "hook.coverage",
2270
+ SEV_WARN,
2271
+ f"{len(non_executable)} script(s) not executable: " + "; ".join(non_executable[:5]),
2272
+ detail,
2273
+ )
2274
+ )
2275
+ return
2276
+ if orphan_pairs and adapter_caps:
2277
+ report.checks.append(
2278
+ CheckResult(
2279
+ "hook.coverage",
2280
+ SEV_WARN,
2281
+ f"{len(orphan_pairs)} event/matcher pair(s) renderable for ZERO adapter — "
2282
+ f"may be intentional (e.g. SubagentStart Codex-incompatible). First: "
2283
+ + "; ".join(orphan_pairs[:5]),
2284
+ detail,
2285
+ )
2286
+ )
2287
+ return
2288
+ report.checks.append(
2289
+ CheckResult(
2290
+ "hook.coverage",
2291
+ SEV_PASS,
2292
+ f"{total_hooks} hooks · {total_pairs} pairs · {len(adapter_caps)} adapter(s) scanned — all renderable",
2293
+ detail,
2294
+ )
2295
+ )
2296
+
2297
+
2298
+ def _check_presence_zombies(project: Path, report: DoctorReport) -> None:
2299
+ """presence.no_zombies — flag presence files where ended_at is null AND PID is dead AND
2300
+ age >1h. These are crashed sessions that the lazy GC could not reap
2301
+ on its own (Codex+Cursor lack Stop/SessionEnd matchers as of 2026-04).
2302
+ Warns at >20 zombies so the live-agents board can't accumulate noise.
2303
+ """
2304
+ import time as _time
2305
+
2306
+ sessions_root = project / ".coding-os"
2307
+ if not sessions_root.is_dir():
2308
+ report.checks.append(
2309
+ CheckResult(
2310
+ "presence.no_zombies",
2311
+ SEV_PASS,
2312
+ "no .coding-os/ (skip)",
2313
+ )
2314
+ )
2315
+ return
2316
+
2317
+ threshold = 3600
2318
+ now = int(_time.time())
2319
+ zombies: dict[str, int] = {}
2320
+ total_files = 0
2321
+ for agent_dir in sessions_root.iterdir():
2322
+ if not agent_dir.is_dir():
2323
+ continue
2324
+ sess_dir = agent_dir / "sessions"
2325
+ if not sess_dir.is_dir():
2326
+ continue
2327
+ count = 0
2328
+ for path in sess_dir.glob("*.json"):
2329
+ total_files += 1
2330
+ try:
2331
+ mtime = path.stat().st_mtime
2332
+ except OSError:
2333
+ continue
2334
+ if now - mtime <= threshold:
2335
+ continue
2336
+ try:
2337
+ data = json.loads(path.read_text(encoding="utf-8"))
2338
+ except (OSError, json.JSONDecodeError):
2339
+ count += 1
2340
+ continue
2341
+ if data.get("ended_at") is not None:
2342
+ continue
2343
+ pid_raw = data.get("pid") or 0
2344
+ try:
2345
+ pid = int(pid_raw)
2346
+ except (TypeError, ValueError):
2347
+ pid = 0
2348
+ alive = False
2349
+ if pid > 0:
2350
+ try:
2351
+ os.kill(pid, 0)
2352
+ alive = True
2353
+ except ProcessLookupError:
2354
+ alive = False
2355
+ except PermissionError:
2356
+ alive = True
2357
+ except OSError:
2358
+ alive = False
2359
+ if not alive:
2360
+ count += 1
2361
+ if count:
2362
+ zombies[agent_dir.name] = count
2363
+
2364
+ detail = {
2365
+ "total_files": total_files,
2366
+ "zombies_per_agent": zombies,
2367
+ "threshold_secs": threshold,
2368
+ }
2369
+ total_zombies = sum(zombies.values())
2370
+ if total_zombies == 0:
2371
+ report.checks.append(
2372
+ CheckResult(
2373
+ "presence.no_zombies",
2374
+ SEV_PASS,
2375
+ f"0 zombies across {total_files} session file(s)",
2376
+ detail,
2377
+ )
2378
+ )
2379
+ return
2380
+ if total_zombies > 20:
2381
+ report.checks.append(
2382
+ CheckResult(
2383
+ "presence.no_zombies",
2384
+ SEV_WARN,
2385
+ f"{total_zombies} zombie session file(s) — run `cos hooks-list` "
2386
+ "or trigger any agent tool call to fire presence_gc.py",
2387
+ detail,
2388
+ )
2389
+ )
2390
+ return
2391
+ report.checks.append(
2392
+ CheckResult(
2393
+ "presence.no_zombies",
2394
+ SEV_PASS,
2395
+ f"{total_zombies} zombie file(s) (<20 threshold) — GC will reap on next tick",
2396
+ detail,
2397
+ )
2398
+ )
2399
+
2400
+
2401
+ def _check_scheduled(project: Path, report: DoctorReport) -> None:
2402
+ """scheduled.cron_configured — nightly cron: plist installed + loaded, no failures, run < 2d ago."""
2403
+ import datetime as _datetime
2404
+ import platform as _platform
2405
+
2406
+ plist_dest = Path.home() / "Library" / "LaunchAgents" / "com.codingos.nightly.plist"
2407
+ last_run_path = project / ".coding-os" / "scheduled" / "last_run.json"
2408
+ is_macos = _platform.system() == "Darwin"
2409
+ plist_ok = True
2410
+
2411
+ if is_macos:
2412
+ if not plist_dest.exists():
2413
+ report.checks.append(
2414
+ CheckResult(
2415
+ "scheduled.cron_configured",
2416
+ SEV_WARN,
2417
+ "nightly cron not installed — run `cos cron install`",
2418
+ {"plist": str(plist_dest)},
2419
+ )
2420
+ )
2421
+ return
2422
+ try:
2423
+ r = subprocess.run(
2424
+ ["launchctl", "list", "com.codingos.nightly"],
2425
+ capture_output=True,
2426
+ timeout=5,
2427
+ )
2428
+ if r.returncode != 0:
2429
+ report.checks.append(
2430
+ CheckResult(
2431
+ "scheduled.cron_configured",
2432
+ SEV_WARN,
2433
+ "plist present but not loaded — run `cos cron install`",
2434
+ {"plist": str(plist_dest)},
2435
+ )
2436
+ )
2437
+ return
2438
+ except OSError as exc:
2439
+ logger.debug("launchctl probe failed: %s", exc)
2440
+ plist_ok = False
2441
+
2442
+ if not last_run_path.exists():
2443
+ prefix = "plist installed + loaded" if (is_macos and plist_ok) else "cron configured"
2444
+ report.checks.append(
2445
+ CheckResult(
2446
+ "scheduled.cron_configured",
2447
+ SEV_PASS,
2448
+ f"{prefix}, no run yet — run `cos cron run` to test",
2449
+ )
2450
+ )
2451
+ return
2452
+
2453
+ try:
2454
+ data = json.loads(last_run_path.read_text(encoding="utf-8"))
2455
+ except (json.JSONDecodeError, OSError) as exc:
2456
+ report.checks.append(
2457
+ CheckResult(
2458
+ "scheduled.cron_configured",
2459
+ SEV_WARN,
2460
+ f"cannot read last_run.json: {exc}",
2461
+ {"path": str(last_run_path)},
2462
+ )
2463
+ )
2464
+ return
2465
+
2466
+ disabled = data.get("disabled_reason")
2467
+ if disabled:
2468
+ report.checks.append(
2469
+ CheckResult(
2470
+ "scheduled.cron_configured",
2471
+ SEV_FAIL,
2472
+ f"auto-disabled: {disabled} — run `cos cron run --reset-failures`",
2473
+ {"disabled_reason": disabled, "last_error": data.get("last_error")},
2474
+ )
2475
+ )
2476
+ return
2477
+
2478
+ failures = int(data.get("consecutive_failures") or 0)
2479
+ if failures >= 3:
2480
+ report.checks.append(
2481
+ CheckResult(
2482
+ "scheduled.cron_configured",
2483
+ SEV_FAIL,
2484
+ f"{failures} consecutive failures — run `cos cron run --reset-failures`",
2485
+ {"consecutive_failures": failures, "last_error": data.get("last_error")},
2486
+ )
2487
+ )
2488
+ return
2489
+
2490
+ run_at = (data.get("run_at") or "")[:19]
2491
+ if run_at:
2492
+ try:
2493
+ run_dt = _datetime.datetime.fromisoformat(run_at).replace(tzinfo=_datetime.timezone.utc)
2494
+ now = _datetime.datetime.now(_datetime.timezone.utc)
2495
+ age_days = (now - run_dt).total_seconds() / 86400
2496
+ if age_days > 2:
2497
+ report.checks.append(
2498
+ CheckResult(
2499
+ "scheduled.cron_configured",
2500
+ SEV_WARN,
2501
+ f"last run {age_days:.1f}d ago — is launchd running?",
2502
+ {"run_at": run_at, "age_days": round(age_days, 1)},
2503
+ )
2504
+ )
2505
+ return
2506
+ except (ValueError, TypeError) as exc:
2507
+ logger.debug("run_at parse failed: %s", exc)
2508
+
2509
+ parts: list[str] = []
2510
+ if is_macos and plist_ok:
2511
+ parts.append("plist loaded")
2512
+ if failures:
2513
+ parts.append(f"failures={failures}")
2514
+ if run_at:
2515
+ parts.append(f"last={run_at[:10]}")
2516
+ report.checks.append(
2517
+ CheckResult(
2518
+ "scheduled.cron_configured",
2519
+ SEV_PASS,
2520
+ ", ".join(parts) if parts else "healthy",
2521
+ {"consecutive_failures": failures, "run_at": run_at or None},
2522
+ )
2523
+ )
2524
+
2525
+
2526
+ def _format_text(report: DoctorReport, *, strict: bool) -> str:
2527
+ header = (
2528
+ f"Coding OS Doctor — {report.project_dir}\n"
2529
+ f"Agent: {report.agent or '?'} Templates: {', '.join(report.templates) or 'none'}\n"
2530
+ + "="
2531
+ * 60
2532
+ )
2533
+ lines = [header]
2534
+ ordered_checks = sorted(report.checks, key=lambda c: (c.category, c.name))
2535
+ current_category: str | None = None
2536
+ for c in ordered_checks:
2537
+ if c.category != current_category:
2538
+ current_category = c.category
2539
+ lines.append("")
2540
+ lines.append(f"── {c.category} ──")
2541
+ badge = {"PASS": "✅", "WARN": "⚠️ ", "FAIL": "❌"}[c.severity]
2542
+ lines.append(f" {badge} {c.id:42s} {c.message}")
2543
+ lines.append("")
2544
+ s = report.summary()
2545
+ lines.append("-" * 60)
2546
+ exit_code = report.exit_code(strict=strict)
2547
+ status_icon = "✅" if exit_code == 0 else "❌"
2548
+ lines.append(
2549
+ f"{status_icon} Summary: {s['pass']} PASS, {s['warn']} WARN, {s['fail']} FAIL "
2550
+ f"(exit={exit_code})"
2551
+ )
2552
+ if report.suppressed:
2553
+ lines.append(
2554
+ f" suppressed: {report.suppressed} check(s) via {', '.join(report.suppressed_globs)}"
2555
+ )
2556
+ return "\n".join(lines)
2557
+
2558
+
2559
+ def _format_json(report: DoctorReport, *, strict: bool) -> str:
2560
+ payload = {
2561
+ "project_dir": report.project_dir,
2562
+ "agent": report.agent,
2563
+ "templates": report.templates,
2564
+ "checks": [{**asdict(c), "category": c.category, "name": c.name} for c in report.checks],
2565
+ "summary": {**report.summary(), "exit_code": report.exit_code(strict=strict)},
2566
+ }
2567
+ return json.dumps(payload, indent=2)
2568
+
2569
+
2570
+ def _probe_agent_sdk() -> None:
2571
+ import importlib.metadata
2572
+ import os
2573
+ import shutil
2574
+ import subprocess
2575
+ from pathlib import Path
2576
+
2577
+ import yaml
2578
+
2579
+ target_id = os.environ.get("COS_AGENT", "")
2580
+ adapters_root = Path(__file__).resolve().parent.parent.parent / "src" / "adapters"
2581
+ if not target_id:
2582
+ for adapter_dir in sorted(adapters_root.iterdir()):
2583
+ if adapter_dir.is_dir() and (adapter_dir / "adapter.yaml").exists():
2584
+ target_id = adapter_dir.name
2585
+ break
2586
+
2587
+ meta_path = adapters_root / target_id / "adapter.yaml"
2588
+ adapter = yaml.safe_load(meta_path.read_text(encoding="utf-8")) if meta_path.exists() else {}
2589
+ cli_binary = adapter.get("cli_binary") or target_id
2590
+ sdk_package = adapter.get("sdk_package") or ""
2591
+ sdk_optional_extra = adapter.get("sdk_optional_extra") or ""
2592
+ label = adapter.get("label") or target_id
2593
+
2594
+ click.echo(f"{label} SDK compatibility report")
2595
+ click.echo("=" * 60)
2596
+
2597
+ if sdk_package:
2598
+ try:
2599
+ sdk_version = importlib.metadata.version(sdk_package)
2600
+ click.echo(f" [OK] {sdk_package} = {sdk_version}")
2601
+ except importlib.metadata.PackageNotFoundError:
2602
+ if sdk_optional_extra:
2603
+ click.echo(
2604
+ f" [WARN] {sdk_package} not installed "
2605
+ f"(uv sync --extra {sdk_optional_extra}; CLI fallback remains available)"
2606
+ )
2607
+ else:
2608
+ click.echo(f" [FAIL] {sdk_package} not installed (uv sync --extra rag)")
2609
+ else:
2610
+ click.echo(" [SKIP] no sdk_package declared in adapter.yaml")
2611
+
2612
+ cli_path = shutil.which(cli_binary)
2613
+ if cli_path:
2614
+ try:
2615
+ result = subprocess.run(
2616
+ [cli_path, "--version"], capture_output=True, text=True, timeout=5
2617
+ )
2618
+ cli_version = result.stdout.strip() or result.stderr.strip()
2619
+ click.echo(f" [OK] {cli_binary} CLI = {cli_version} ({cli_path})")
2620
+ except (subprocess.TimeoutExpired, OSError) as exc:
2621
+ click.echo(f" [WARN] {cli_binary} CLI unreachable: {exc}")
2622
+ else:
2623
+ click.echo(f" [WARN] {cli_binary} CLI not on PATH")
2624
+
2625
+ auth_env_vars = [str(name) for name in adapter.get("auth_env_vars", []) if name]
2626
+ configured_auth = [name for name in auth_env_vars if os.environ.get(name)]
2627
+ if configured_auth:
2628
+ click.echo(f" [OK] {configured_auth[0]} set")
2629
+ elif auth_env_vars:
2630
+ click.echo(
2631
+ f" [WARN] none of {', '.join(auth_env_vars)} set "
2632
+ "(CLI-managed login may still be valid)"
2633
+ )
2634
+ else:
2635
+ click.echo(" [SKIP] no auth_env_vars declared in adapter.yaml")
2636
+
2637
+ for marker in adapter.get("runtime_env_markers", []):
2638
+ value = os.environ.get(str(marker))
2639
+ if value:
2640
+ click.echo(f" [OK] {marker} = {value!r}")
2641
+
2642
+ mcp_paths: list[Path] = []
2643
+ for entry in adapter.get("mcp_launch", {}).get("config_paths", []):
2644
+ if not isinstance(entry, dict) or not entry.get("path"):
2645
+ continue
2646
+ base = Path.home() if entry.get("scope") == "home" else Path.cwd()
2647
+ mcp_paths.append(base / str(entry["path"]))
2648
+ present_mcp_paths = [path for path in mcp_paths if path.exists()]
2649
+ if present_mcp_paths:
2650
+ click.echo(f" [OK] MCP config present ({present_mcp_paths[0].resolve()})")
2651
+ elif mcp_paths:
2652
+ expected = ", ".join(str(path) for path in mcp_paths)
2653
+ click.echo(f" [WARN] MCP config missing ({expected})")
2654
+ else:
2655
+ click.echo(" [SKIP] no MCP config paths declared in adapter.yaml")
2656
+
2657
+
2658
+ def _probe_otel() -> None:
2659
+ """Print OTEL configuration table for cos doctor --otel (T8.3)."""
2660
+ import os
2661
+ import socket
2662
+
2663
+ _VARS = [
2664
+ "OTEL_TRACES_EXPORTER",
2665
+ "OTEL_METRICS_EXPORTER",
2666
+ "OTEL_LOGS_EXPORTER",
2667
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
2668
+ "OTEL_EXPORTER_OTLP_PROTOCOL",
2669
+ "OTEL_EXPORTER_OTLP_HEADERS",
2670
+ "OTEL_RESOURCE_ATTRIBUTES",
2671
+ "OTEL_SERVICE_NAME",
2672
+ "CLAUDE_CODE_ENABLE_TELEMETRY",
2673
+ ]
2674
+ configured = {v: os.environ.get(v) for v in _VARS}
2675
+ click.echo("OTEL probe")
2676
+ click.echo("=" * 60)
2677
+ for var, val in configured.items():
2678
+ if val:
2679
+ click.echo(f" [OK] {var} = {val!r}")
2680
+ else:
2681
+ click.echo(f" [--] {var} = not set")
2682
+
2683
+ endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "")
2684
+ if endpoint:
2685
+ click.echo("")
2686
+ click.echo(f"Probing endpoint: {endpoint}")
2687
+ try:
2688
+ from urllib.parse import urlparse as _up
2689
+
2690
+ parsed = _up(endpoint)
2691
+ host = parsed.hostname or "localhost"
2692
+ port = parsed.port or (443 if parsed.scheme == "https" else 4317)
2693
+ with socket.create_connection((host, port), timeout=3):
2694
+ click.echo(f" [OK] TCP {host}:{port} reachable")
2695
+ except OSError as exc:
2696
+ click.echo(f" [ERR] TCP unreachable: {exc}")
2697
+ else:
2698
+ click.echo("\nNo OTEL_EXPORTER_OTLP_ENDPOINT set — local stdout exporter assumed.")
2699
+
2700
+
2701
+ _BOOTSTRAP_MIN_PYTHON = (3, 10)
2702
+ _BOOTSTRAP_MIN_BASH_MAJOR = 4
2703
+
2704
+
2705
+ def _capture_tool_version(executable: str) -> str | None:
2706
+ """First line of `<tool> --version`, or None when the tool is absent."""
2707
+ import shutil
2708
+
2709
+ if shutil.which(executable) is None:
2710
+ return None
2711
+ try:
2712
+ proc = subprocess.run([executable, "--version"], capture_output=True, text=True, timeout=10)
2713
+ except (OSError, subprocess.TimeoutExpired):
2714
+ return None
2715
+ text = (proc.stdout or proc.stderr or "").strip()
2716
+ return text.splitlines()[0] if text else ""
2717
+
2718
+
2719
+ def _check_bootstrap_python(report: DoctorReport) -> None:
2720
+ found = sys.version_info[:2]
2721
+ label = f"python {found[0]}.{found[1]}"
2722
+ if found >= _BOOTSTRAP_MIN_PYTHON:
2723
+ report.checks.append(CheckResult("bootstrap.python_version", SEV_PASS, label))
2724
+ else:
2725
+ report.checks.append(
2726
+ CheckResult(
2727
+ "bootstrap.python_version",
2728
+ SEV_FAIL,
2729
+ f"{label} < {_BOOTSTRAP_MIN_PYTHON[0]}.{_BOOTSTRAP_MIN_PYTHON[1]} — "
2730
+ "install a newer Python and reinstall cos with it",
2731
+ )
2732
+ )
2733
+
2734
+
2735
+ def _check_bootstrap_bash(report: DoctorReport) -> None:
2736
+ banner = _capture_tool_version("bash")
2737
+ if banner is None:
2738
+ report.checks.append(
2739
+ CheckResult(
2740
+ "bootstrap.bash_version",
2741
+ SEV_FAIL,
2742
+ "bash not found on PATH — hook scripts require bash >= 4",
2743
+ )
2744
+ )
2745
+ return
2746
+ match = re.search(r"version (\d+)\.(\d+)", banner)
2747
+ major = int(match.group(1)) if match else 0
2748
+ if major >= _BOOTSTRAP_MIN_BASH_MAJOR:
2749
+ report.checks.append(CheckResult("bootstrap.bash_version", SEV_PASS, banner))
2750
+ else:
2751
+ report.checks.append(
2752
+ CheckResult(
2753
+ "bootstrap.bash_version",
2754
+ SEV_FAIL,
2755
+ f"{banner} — hooks need bash >= 4 (macOS ships 3.2: brew install bash)",
2756
+ )
2757
+ )
2758
+
2759
+
2760
+ def _check_bootstrap_git(report: DoctorReport) -> None:
2761
+ banner = _capture_tool_version("git")
2762
+ if banner is None:
2763
+ report.checks.append(
2764
+ CheckResult(
2765
+ "bootstrap.git_present",
2766
+ SEV_FAIL,
2767
+ "git not found — `cos init` runs git init "
2768
+ "(macOS: xcode-select --install · debian: apt install git)",
2769
+ )
2770
+ )
2771
+ else:
2772
+ report.checks.append(CheckResult("bootstrap.git_present", SEV_PASS, banner))
2773
+
2774
+
2775
+ def _check_bootstrap_uv(report: DoctorReport) -> None:
2776
+ banner = _capture_tool_version("uv")
2777
+ if banner is None:
2778
+ report.checks.append(
2779
+ CheckResult(
2780
+ "bootstrap.uv_present",
2781
+ SEV_WARN,
2782
+ "uv not found — updates and extras install through it "
2783
+ "(curl -LsSf https://astral.sh/uv/install.sh | sh)",
2784
+ )
2785
+ )
2786
+ else:
2787
+ report.checks.append(CheckResult("bootstrap.uv_present", SEV_PASS, banner))
2788
+
2789
+
2790
+ def _check_bootstrap_sed(report: DoctorReport) -> None:
2791
+ banner = _capture_tool_version("sed")
2792
+ if banner is None:
2793
+ report.checks.append(CheckResult("bootstrap.sed_flavor", SEV_WARN, "sed not found on PATH"))
2794
+ return
2795
+ flavor = "gnu" if "GNU" in banner else "bsd"
2796
+ report.checks.append(
2797
+ CheckResult("bootstrap.sed_flavor", SEV_PASS, f"{flavor} sed detected", {"flavor": flavor})
2798
+ )
2799
+
2800
+
2801
+ def run_bootstrap_doctor() -> DoctorReport:
2802
+ """Preflight prerequisite checks — no initialized project required.
2803
+
2804
+ Encodes README § Prerequisites; check docs live in
2805
+ docs/playbooks/doctor-checks.md § bootstrap (TASK-347).
2806
+ """
2807
+ report = DoctorReport(project_dir="-", agent=None, templates=[])
2808
+ _check_bootstrap_python(report)
2809
+ _check_bootstrap_bash(report)
2810
+ _check_bootstrap_git(report)
2811
+ _check_bootstrap_uv(report)
2812
+ _check_bootstrap_sed(report)
2813
+ return report
2814
+
2815
+
2816
+ @click.command()
2817
+ @click.option("--project-dir", "-d", default=".", help="Project directory (default: cwd)")
2818
+ @click.option(
2819
+ "--format",
2820
+ "output_format",
2821
+ type=click.Choice(["text", "json"]),
2822
+ default="text",
2823
+ help="Output format",
2824
+ )
2825
+ @click.option("--strict", is_flag=True, default=False, help="Promote WARN to exit 1")
2826
+ @click.option("--manifest", default=None, help="Override manifest file path")
2827
+ @click.option("--otel", is_flag=True, default=False, help="Probe OTEL exporter config and exit")
2828
+ @click.option(
2829
+ "--bootstrap",
2830
+ is_flag=True,
2831
+ default=False,
2832
+ help="Preflight prerequisite checks (python/bash/git/uv/sed) — no project needed",
2833
+ )
2834
+ @click.option(
2835
+ "--agent-sdk",
2836
+ "--claude-sdk",
2837
+ "agent_sdk",
2838
+ is_flag=True,
2839
+ default=False,
2840
+ help="Print the active adapter SDK + CLI compatibility report and exit",
2841
+ )
2842
+ @click.option(
2843
+ "--ignore",
2844
+ "ignore_globs",
2845
+ multiple=True,
2846
+ help="Skip checks whose dotted ID matches this fnmatch glob (e.g. 'graph.*'). "
2847
+ "Repeatable. Merged with .coding-os.yaml::doctor.ignore.",
2848
+ )
2849
+ @click.option(
2850
+ "--explain",
2851
+ "explain_id",
2852
+ default=None,
2853
+ help="Print the docs/playbooks/doctor-checks.md section for the given check ID and exit.",
2854
+ )
2855
+ @click.option(
2856
+ "--tokens",
2857
+ "tokens",
2858
+ is_flag=True,
2859
+ default=False,
2860
+ help="Token-usage audit of agent transcripts (probe-and-exit, like --otel)",
2861
+ )
2862
+ @click.option(
2863
+ "--days",
2864
+ "tokens_days",
2865
+ type=int,
2866
+ default=7,
2867
+ help="Window for --tokens (default 7 days)",
2868
+ )
2869
+ @click.option(
2870
+ "--structure",
2871
+ "structure",
2872
+ is_flag=True,
2873
+ default=False,
2874
+ help="Validate the src/ tree against the declared project anatomy and exit",
2875
+ )
2876
+ def doctor(
2877
+ project_dir: str,
2878
+ output_format: str,
2879
+ strict: bool,
2880
+ manifest: str | None,
2881
+ otel: bool,
2882
+ bootstrap: bool,
2883
+ agent_sdk: bool,
2884
+ ignore_globs: tuple[str, ...],
2885
+ explain_id: str | None,
2886
+ tokens: bool,
2887
+ tokens_days: int,
2888
+ structure: bool,
2889
+ ) -> None:
2890
+ """Deep health check: scaffold, DB schema, adapter, manifest, MCP."""
2891
+ if tokens:
2892
+ from cli.doctor_tokens import (
2893
+ analyze_dispatch_cost,
2894
+ analyze_tokens,
2895
+ format_dispatch_cost_text,
2896
+ format_tokens_text,
2897
+ )
2898
+
2899
+ proj = Path(project_dir).resolve()
2900
+ token_report = analyze_tokens(proj, days=tokens_days)
2901
+ cost_report = analyze_dispatch_cost(proj)
2902
+ if output_format == "json":
2903
+ click.echo(json.dumps({**token_report, "dispatch_cost": cost_report}, indent=2))
2904
+ else:
2905
+ click.echo(format_tokens_text(token_report))
2906
+ cost_text = format_dispatch_cost_text(cost_report)
2907
+ if cost_text:
2908
+ click.echo(cost_text)
2909
+ return
2910
+ if bootstrap:
2911
+ report = run_bootstrap_doctor()
2912
+ if output_format == "json":
2913
+ click.echo(_format_json(report, strict=strict))
2914
+ else:
2915
+ click.echo(_format_text(report, strict=strict))
2916
+ sys.exit(report.exit_code(strict=strict))
2917
+ if otel:
2918
+ _probe_otel()
2919
+ return
2920
+ if agent_sdk:
2921
+ _probe_agent_sdk()
2922
+ return
2923
+ if explain_id:
2924
+ click.echo(_explain_check(explain_id))
2925
+ return
2926
+ if structure:
2927
+ project = Path(project_dir).resolve()
2928
+ report = DoctorReport(project_dir=str(project), agent=None, templates=[])
2929
+ config_path = project / CONFIG_FILE
2930
+ config: dict[str, Any] | None = None
2931
+ if config_path.is_file():
2932
+ try:
2933
+ config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
2934
+ except yaml.YAMLError:
2935
+ config = None
2936
+ _check_structure(project, report, config)
2937
+ if output_format == "json":
2938
+ click.echo(_format_json(report, strict=strict))
2939
+ else:
2940
+ click.echo(_format_text(report, strict=strict))
2941
+ sys.exit(report.exit_code(strict=strict))
2942
+ project = Path(project_dir).resolve()
2943
+ manifest_path = Path(manifest).resolve() if manifest else None
2944
+ report = run_doctor(
2945
+ project,
2946
+ manifest_path=manifest_path,
2947
+ extra_ignores=list(ignore_globs) if ignore_globs else None,
2948
+ )
2949
+ if output_format == "json":
2950
+ click.echo(_format_json(report, strict=strict))
2951
+ else:
2952
+ click.echo(_format_text(report, strict=strict))
2953
+ sys.exit(report.exit_code(strict=strict))