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.
- adapters/claude/README.md +6 -0
- adapters/claude/_install_helpers/extract_stacks.py +43 -0
- adapters/claude/_install_helpers/update_mcp_json.py +75 -0
- adapters/claude/adapter.yaml +164 -0
- adapters/claude/hooks/README.md +40 -0
- adapters/claude/hooks/agent_memory_sync.py +131 -0
- adapters/claude/hooks/ensure-agent-memory-link.sh +36 -0
- adapters/claude/hooks/sync-agent-memory.sh +21 -0
- adapters/claude/install.sh +86 -0
- adapters/claude/sdk_dispatcher.py +871 -0
- adapters/claude/settings.local.template.json +31 -0
- adapters/claude/settings.template.json +808 -0
- adapters/claude/update_mcp_json.py +85 -0
- adapters/codex/adapter.yaml +254 -0
- adapters/codex/chat_provider.py +230 -0
- adapters/codex/commands/formula-f1.md +129 -0
- adapters/codex/commands/formula-f10.md +100 -0
- adapters/codex/commands/formula-f11.md +123 -0
- adapters/codex/commands/formula-f2.md +139 -0
- adapters/codex/commands/formula-f3.md +127 -0
- adapters/codex/commands/formula-f4.md +101 -0
- adapters/codex/commands/formula-f5.md +135 -0
- adapters/codex/commands/formula-f6.md +147 -0
- adapters/codex/commands/formula-f7.md +111 -0
- adapters/codex/commands/formula-f8.md +133 -0
- adapters/codex/commands/formula-f9.md +112 -0
- adapters/codex/enable_codex_hooks.py +94 -0
- adapters/codex/ensure_codex_mcp.py +124 -0
- adapters/codex/hooks/codex-merge-hook-output.py +72 -0
- adapters/codex/hooks/codex-normalize-edit.py +96 -0
- adapters/codex/hooks/codex-postedit-dispatch.sh +75 -0
- adapters/codex/hooks/codex-posttool-dispatch.sh +70 -0
- adapters/codex/hooks/codex-preedit-dispatch.sh +83 -0
- adapters/codex/hooks/codex-pretool-dispatch.sh +82 -0
- adapters/codex/hooks/codex-sessionend-dispatch.sh +20 -0
- adapters/codex/hooks/codex-sessionstart-dispatch.sh +68 -0
- adapters/codex/hooks/codex-stop-dispatch.sh +73 -0
- adapters/codex/hooks/codex-userpromptsubmit-dispatch.sh +74 -0
- adapters/codex/hooks.template.json +208 -0
- adapters/codex/install.sh +83 -0
- adapters/codex/sdk_dispatcher.py +449 -0
- board_os/__init__.py +39 -0
- board_os/_agent_runtime.py +256 -0
- board_os/config.py +421 -0
- board_os/git_coherence.py +107 -0
- board_os/hub_adapter_manifest.py +140 -0
- board_os/mcp_tools.py +3228 -0
- board_os/migration.py +166 -0
- board_os/parser.py +317 -0
- board_os/presence.py +156 -0
- board_os/sync.py +320 -0
- board_os/transition_gates.py +224 -0
- board_os/transition_gates_cli.py +272 -0
- board_os/transition_gates_validator.py +551 -0
- board_os/verify_suites.py +126 -0
- board_os/verify_suites_cli.py +328 -0
- board_os/workflow.py +967 -0
- cli/__init__.py +0 -0
- cli/_data_types.py +248 -0
- cli/_init_helpers.py +587 -0
- cli/_resources.py +100 -0
- cli/adapter_registry.py +239 -0
- cli/add_stack.py +315 -0
- cli/aggregator.py +438 -0
- cli/board_commands.py +1218 -0
- cli/brain_commands.py +255 -0
- cli/cognition.py +345 -0
- cli/config_composer.py +349 -0
- cli/core_version.py +41 -0
- cli/cron_commands.py +278 -0
- cli/db_reset.py +298 -0
- cli/doc_commands.py +111 -0
- cli/doctor.py +2953 -0
- cli/doctor_board.py +365 -0
- cli/doctor_extras.py +1121 -0
- cli/doctor_graph.py +608 -0
- cli/doctor_tokens.py +254 -0
- cli/graph_commands.py +1265 -0
- cli/hook_renderer.py +393 -0
- cli/hub_commands.py +580 -0
- cli/list_adapters.py +79 -0
- cli/list_stacks.py +105 -0
- cli/logs_commands.py +89 -0
- cli/main.py +3070 -0
- cli/materialize_file.py +65 -0
- cli/mcp_start.py +153 -0
- cli/module_commands.py +513 -0
- cli/pr_commands.py +2024 -0
- cli/preset_commands.py +126 -0
- cli/preset_registry.py +171 -0
- cli/project_overrides.py +119 -0
- cli/registry.py +365 -0
- cli/remove_stack.py +492 -0
- cli/renderer.py +620 -0
- cli/setup.py +464 -0
- cli/skill_commands.py +689 -0
- cli/skill_registry.py +235 -0
- cli/skills_list.py +332 -0
- cli/stack_lint.py +351 -0
- cli/stack_registry.py +688 -0
- cli/subsystems.py +335 -0
- cli/sync_all.py +310 -0
- cli/tail_command.py +410 -0
- cli/update.py +608 -0
- cli/verify_since_edit.py +439 -0
- coding_os-0.3.2.dist-info/METADATA +508 -0
- coding_os-0.3.2.dist-info/RECORD +1304 -0
- coding_os-0.3.2.dist-info/WHEEL +5 -0
- coding_os-0.3.2.dist-info/entry_points.txt +5 -0
- coding_os-0.3.2.dist-info/licenses/LICENSE +201 -0
- coding_os-0.3.2.dist-info/top_level.txt +10 -0
- core/__init__.py +0 -0
- core/board_os/__init__.py +39 -0
- core/board_os/_agent_runtime.py +256 -0
- core/board_os/config.py +421 -0
- core/board_os/git_coherence.py +107 -0
- core/board_os/hub_adapter_manifest.py +140 -0
- core/board_os/mcp_tools.py +3228 -0
- core/board_os/migration.py +166 -0
- core/board_os/parser.py +317 -0
- core/board_os/presence.py +156 -0
- core/board_os/sync.py +320 -0
- core/board_os/transition-gates.yaml +176 -0
- core/board_os/transition_gates.py +224 -0
- core/board_os/transition_gates_cli.py +272 -0
- core/board_os/transition_gates_validator.py +551 -0
- core/board_os/verify-suites.yaml +113 -0
- core/board_os/verify_suites.py +126 -0
- core/board_os/verify_suites_cli.py +328 -0
- core/board_os/workflow.py +967 -0
- core/commands/board.md +27 -0
- core/commands/classify.md +23 -0
- core/commands/compose.md +23 -0
- core/commands/daily.md +31 -0
- core/commands/diagnose.md +7 -0
- core/commands/memory-search.md +23 -0
- core/commands/new-project.md +33 -0
- core/commands/retro.md +38 -0
- core/commands/review.md +14 -0
- core/commands/task.md +17 -0
- core/commands/verify.md +34 -0
- core/docs/thinking_os-final-edition.md +1449 -0
- core/doctor-config.yaml +74 -0
- core/graph_os/__init__.py +29 -0
- core/graph_os/backend.py +233 -0
- core/graph_os/backends/__init__.py +13 -0
- core/graph_os/backends/sqlite_backend.py +1053 -0
- core/graph_os/bench/__init__.py +17 -0
- core/graph_os/bench/fixtures.py +56 -0
- core/graph_os/bench/harness.py +95 -0
- core/graph_os/bench/persian_precision.py +142 -0
- core/graph_os/bench/scale_500k.py +120 -0
- core/graph_os/bench/token_cost.py +170 -0
- core/graph_os/bench/viewer_fps.py +110 -0
- core/graph_os/communities.py +410 -0
- core/graph_os/enterprise.py +218 -0
- core/graph_os/entry_points.py +226 -0
- core/graph_os/extractors/__init__.py +25 -0
- core/graph_os/extractors/code_generic.py +914 -0
- core/graph_os/extractors/code_go.py +1422 -0
- core/graph_os/extractors/code_json.py +340 -0
- core/graph_os/extractors/code_php.py +979 -0
- core/graph_os/extractors/code_python.py +1454 -0
- core/graph_os/extractors/code_shell.py +538 -0
- core/graph_os/extractors/code_toml.py +302 -0
- core/graph_os/extractors/code_ts.py +1665 -0
- core/graph_os/extractors/code_yaml.py +394 -0
- core/graph_os/extractors/contracts.py +1592 -0
- core/graph_os/extractors/md_links.py +890 -0
- core/graph_os/extractors/task_deps.py +345 -0
- core/graph_os/groups/__init__.py +22 -0
- core/graph_os/groups/cross_repo.py +156 -0
- core/graph_os/groups/manifest.py +141 -0
- core/graph_os/ingest/__init__.py +19 -0
- core/graph_os/ingest/base.py +306 -0
- core/graph_os/ingest/github.py +112 -0
- core/graph_os/ingest/zip.py +95 -0
- core/graph_os/toolchain.py +393 -0
- core/graph_os/tools/__init__.py +9 -0
- core/graph_os/tools/graph.py +5573 -0
- core/graph_os/tools/reindex_dispatch.py +730 -0
- core/graph_os/tree_sitter_overlay.py +235 -0
- core/graph_os/types.py +252 -0
- core/graph_os/vec_index.py +277 -0
- core/graph_os/viewer/__init__.py +12 -0
- core/graph_os/viewer/exporter.py +93 -0
- core/graph_os/viewer/template.py +189 -0
- core/hooks/_helpers/_paths.py +40 -0
- core/hooks/_helpers/advance_role.py +72 -0
- core/hooks/_helpers/auto_compose.py +228 -0
- core/hooks/_helpers/auto_validate_lessons.py +55 -0
- core/hooks/_helpers/branch_guard_check.py +796 -0
- core/hooks/_helpers/check_commit_message.py +108 -0
- core/hooks/_helpers/check_dangerous_rm.py +80 -0
- core/hooks/_helpers/check_git_bypass.py +154 -0
- core/hooks/_helpers/check_git_destructive.py +77 -0
- core/hooks/_helpers/check_settings_write.py +97 -0
- core/hooks/_helpers/consume_override.py +51 -0
- core/hooks/_helpers/context_budget.py +77 -0
- core/hooks/_helpers/cos_say_json.py +103 -0
- core/hooks/_helpers/destructive_edit_check.py +163 -0
- core/hooks/_helpers/detect_status_transition.py +82 -0
- core/hooks/_helpers/digest_regen.py +56 -0
- core/hooks/_helpers/doc_sync_check.py +498 -0
- core/hooks/_helpers/drain_embedding_outbox.py +52 -0
- core/hooks/_helpers/extract_additional_context.py +51 -0
- core/hooks/_helpers/extract_commit_msg_arg.py +74 -0
- core/hooks/_helpers/git_command_parse.py +424 -0
- core/hooks/_helpers/git_settings_fields.py +47 -0
- core/hooks/_helpers/graph_context_match.py +37 -0
- core/hooks/_helpers/graph_marker_check.py +70 -0
- core/hooks/_helpers/jit_recall.py +56 -0
- core/hooks/_helpers/json_field.py +41 -0
- core/hooks/_helpers/narrative_signal.py +59 -0
- core/hooks/_helpers/observation_count.py +31 -0
- core/hooks/_helpers/pre_commit_batch.py +177 -0
- core/hooks/_helpers/pre_commit_fake_input.py +42 -0
- core/hooks/_helpers/presence_gc.py +102 -0
- core/hooks/_helpers/presence_write.py +167 -0
- core/hooks/_helpers/recover_indirect.py +35 -0
- core/hooks/_helpers/routing_evolution.py +104 -0
- core/hooks/_helpers/session_recap.py +72 -0
- core/hooks/_helpers/skill_primer.py +229 -0
- core/hooks/_helpers/task_sync.py +59 -0
- core/hooks/_helpers/tool_failure_capture.py +147 -0
- core/hooks/_helpers/trajectory_autosnap.py +278 -0
- core/hooks/_helpers/trajectory_startup.py +62 -0
- core/hooks/_helpers/turn_summary.py +82 -0
- core/hooks/_helpers/validate_task_frontmatter.py +98 -0
- core/hooks/_helpers/wip_limit_check.py +103 -0
- core/hooks/_helpers/wip_lines.py +53 -0
- core/hooks/_helpers/work_log_append.py +89 -0
- core/hooks/_helpers/wrap_dispatch_output.py +82 -0
- core/hooks/advance-role.sh +48 -0
- core/hooks/agent-presence.sh +179 -0
- core/hooks/auto-brain-decay.sh +184 -0
- core/hooks/auto-compose-roles.sh +83 -0
- core/hooks/auto-graph-reconcile-shell.sh +119 -0
- core/hooks/auto-regen-doc-index.sh +120 -0
- core/hooks/auto-reindex-docs.sh +130 -0
- core/hooks/auto-task-sync.sh +56 -0
- core/hooks/auto-trace-rotate.sh +88 -0
- core/hooks/block-bad-patterns.sh +212 -0
- core/hooks/block-dangerous-commands.sh +182 -0
- core/hooks/block-hardcoded-literals.sh +90 -0
- core/hooks/block-migration-conflict.sh +114 -0
- core/hooks/block-protected-files.sh +129 -0
- core/hooks/block-secrets.sh +185 -0
- core/hooks/block-shared-tree-edit.sh +75 -0
- core/hooks/block-uv-heredoc.sh +78 -0
- core/hooks/branch-guard.sh +122 -0
- core/hooks/capture-observation.sh +76 -0
- core/hooks/capture-tool-failure.sh +24 -0
- core/hooks/capture-work-log.sh +89 -0
- core/hooks/check-agents-md-refs.sh +75 -0
- core/hooks/check-agents-md-size.sh +49 -0
- core/hooks/check-capture-worked.sh +148 -0
- core/hooks/check-doc-size.sh +61 -0
- core/hooks/check-mcp-extras.sh +92 -0
- core/hooks/check-state.sh +87 -0
- core/hooks/classify-task-mode.sh +103 -0
- core/hooks/cos-env.sh +1301 -0
- core/hooks/drain-embedding-outbox.sh +24 -0
- core/hooks/enforce-anti-ambiguity.sh +74 -0
- core/hooks/enforce-commit-message.sh +73 -0
- core/hooks/enforce-doc-anchor.sh +224 -0
- core/hooks/enforce-doc-sync.sh +206 -0
- core/hooks/enforce-graph-context.sh +89 -0
- core/hooks/enforce-graph-first-read.sh +94 -0
- core/hooks/enforce-memory-check.sh +128 -0
- core/hooks/enforce-rename-plan.sh +78 -0
- core/hooks/enforce-scaffold-boundary.sh +68 -0
- core/hooks/enforce-skill.sh +125 -0
- core/hooks/enforce-task-body.sh +52 -0
- core/hooks/enforce-task-start.sh +81 -0
- core/hooks/enforce-task-transition.sh +75 -0
- core/hooks/enforce-template.sh +143 -0
- core/hooks/enforce-verify.sh +112 -0
- core/hooks/enforce-wip-limit.sh +43 -0
- core/hooks/enforce-zoom.sh +69 -0
- core/hooks/ensure-hub-up.sh +67 -0
- core/hooks/inject-mcp-caller-session.sh +70 -0
- core/hooks/jit-recall.sh +65 -0
- core/hooks/link-commit-to-task.sh +143 -0
- core/hooks/lint-task.sh +40 -0
- core/hooks/nudge-docs-first.sh +71 -0
- core/hooks/nudge-git-mode.sh +29 -0
- core/hooks/nudge-graph-os.sh +118 -0
- core/hooks/nudge-learn-narrative.sh +36 -0
- core/hooks/nudge-model-routing.sh +32 -0
- core/hooks/nudge-reentry.sh +101 -0
- core/hooks/nudge-reuse-first.sh +68 -0
- core/hooks/nudge-task-discovery.sh +81 -0
- core/hooks/nudge-thinking-os.sh +109 -0
- core/hooks/pr-reap.sh +23 -0
- core/hooks/reclaim-sweep.sh +58 -0
- core/hooks/record-verify-auto.sh +77 -0
- core/hooks/record-verify.sh +74 -0
- core/hooks/regen-reminder.sh +104 -0
- core/hooks/registry.yaml +1262 -0
- core/hooks/remind-daily.sh +27 -0
- core/hooks/remind-dogfood.sh +70 -0
- core/hooks/remind-learn-validate.sh +94 -0
- core/hooks/rules-primer.sh +50 -0
- core/hooks/search-enforce-inventory.sh +108 -0
- core/hooks/search-verify-remaining.sh +132 -0
- core/hooks/session-context.sh +729 -0
- core/hooks/session-end.sh +145 -0
- core/hooks/session-skill-primer.sh +43 -0
- core/hooks/snapshot-transcript.sh +56 -0
- core/hooks/sync-task-current.sh +85 -0
- core/hooks/test-first-reminder.sh +120 -0
- core/hooks/test-governor.sh +173 -0
- core/hooks/thinking_os-gate.sh +52 -0
- core/hooks/track-backtrack.sh +35 -0
- core/hooks/track-discovery.sh +121 -0
- core/hooks/track-skill.sh +53 -0
- core/hooks/validate-task-frontmatter.sh +49 -0
- core/hooks/verify-rename-callers.sh +119 -0
- core/hooks/warn-abandoned-task.sh +99 -0
- core/hooks/warn-destructive-edit.sh +64 -0
- core/hooks/warn-diff-size.sh +43 -0
- core/hooks/warn-graph-empty.sh +81 -0
- core/hooks/warn-mcp-down.sh +190 -0
- core/hooks/write-state.sh +55 -0
- core/logging_os/__init__.py +33 -0
- core/logging_os/api.py +127 -0
- core/logging_os/bridge.py +80 -0
- core/logging_os/config.py +172 -0
- core/logging_os/fingerprint.py +25 -0
- core/logging_os/redact.py +53 -0
- core/logging_os/render.py +83 -0
- core/logging_os/sinks.py +164 -0
- core/rules/anti-overengineering.md +44 -0
- core/rules/api-contract-discipline.md +41 -0
- core/rules/dimension-registry.md +155 -0
- core/rules/git-workflow.md +57 -0
- core/rules/memory.md +46 -0
- core/rules/model-routing.md +22 -0
- core/rules/skill-enforcement.md +75 -0
- core/rules/test-discipline.md +38 -0
- core/rules/thinking_os.md +48 -0
- core/rules/transparency-banner.md +37 -0
- core/runtime_paths.yaml +36 -0
- core/scaffold_manifest.json +14430 -0
- core/scheduled/__init__.py +0 -0
- core/scheduled/_activity.py +126 -0
- core/scheduled/_state.py +113 -0
- core/scheduled/config.py +86 -0
- core/scheduled/dep_reconcile.py +135 -0
- core/scheduled/error_sweep.py +137 -0
- core/scheduled/nightly.py +930 -0
- core/scheduled/responsive_extract.py +65 -0
- core/schemas/adapter.schema.json +269 -0
- core/schemas/preset.schema.json +50 -0
- core/schemas/skill.schema.json +81 -0
- core/schemas/stack.schema.json +404 -0
- core/scripts/_lib.sh +9 -0
- core/scripts/docs-lint.sh +228 -0
- core/scripts/docs-nav-fix.sh +133 -0
- core/scripts/docs-staleness-check.sh +154 -0
- core/scripts/install-adapter.sh +266 -0
- core/scripts/link-stack-skills.sh +52 -0
- core/scripts/log-latest.sh +106 -0
- core/scripts/log-search.sh +89 -0
- core/scripts/log-write.sh +134 -0
- core/scripts/ref-resolve.sh +71 -0
- core/skills/a11y/SKILL.md +305 -0
- core/skills/a11y/assets/a11y-checklist.md +137 -0
- core/skills/a11y/references/aria-and-focus.md +247 -0
- core/skills/a11y/references/rn-accessibility.md +343 -0
- core/skills/a11y/references/screen-reader-testing.md +190 -0
- core/skills/agent-memory/SKILL.md +191 -0
- core/skills/agent-memory/assets/memory-checklist.md +21 -0
- core/skills/agent-memory/references/memory-recipes.md +57 -0
- core/skills/api-design/SKILL.md +232 -0
- core/skills/api-design/assets/api-design-checklist.md +110 -0
- core/skills/api-design/references/error-envelope.md +381 -0
- core/skills/api-design/references/idempotency-pagination.md +312 -0
- core/skills/api-design/references/rest-contracts.md +426 -0
- core/skills/auth-patterns/SKILL.md +352 -0
- core/skills/auth-patterns/assets/auth-checklist.md +118 -0
- core/skills/auth-patterns/references/jwt-and-service-tokens.md +343 -0
- core/skills/auth-patterns/references/passkeys-2fa.md +289 -0
- core/skills/auth-patterns/references/sessions-vs-jwt.md +230 -0
- core/skills/auth-patterns/scripts/cookie-flag-check.py +146 -0
- core/skills/backend-fundamentals/SKILL.md +238 -0
- core/skills/backend-fundamentals/assets/backend-checklist.md +27 -0
- core/skills/backend-fundamentals/references/backend-patterns.md +56 -0
- core/skills/backend-fundamentals/scripts/check_layering.py +83 -0
- core/skills/clean-code/SKILL.md +642 -0
- core/skills/clean-code/scripts/audit-fail-closed.py +167 -0
- core/skills/codebase-explorer/SKILL.md +89 -0
- core/skills/codebase-explorer/assets/reading-checklist.md +24 -0
- core/skills/codebase-explorer/references/reading-strategies.md +52 -0
- core/skills/codebase-explorer/scripts/outline.py +99 -0
- core/skills/db-design/SKILL.md +327 -0
- core/skills/db-design/assets/migration-template.sql +49 -0
- core/skills/db-design/references/migration-discipline.md +290 -0
- core/skills/db-design/references/postgres-patterns.md +340 -0
- core/skills/db-design/scripts/migration-safety.sh +150 -0
- core/skills/deployment-cicd/SKILL.md +260 -0
- core/skills/deployment-cicd/assets/deploy-checklist.md +26 -0
- core/skills/deployment-cicd/references/pipeline-and-release.md +54 -0
- core/skills/deployment-cicd/scripts/lint_workflow.py +79 -0
- core/skills/docker/SKILL.md +114 -0
- core/skills/docker/assets/dockerfile-checklist.md +31 -0
- core/skills/docker/references/compose-patterns.md +66 -0
- core/skills/docker/references/dockerfile-optimization.md +64 -0
- core/skills/docker/scripts/lint_dockerfile.sh +48 -0
- core/skills/docker/versions.json +16 -0
- core/skills/end-to-end-testing/SKILL.md +101 -0
- core/skills/end-to-end-testing/assets/e2e-checklist.md +23 -0
- core/skills/end-to-end-testing/references/maestro.md +63 -0
- core/skills/end-to-end-testing/references/playwright.md +68 -0
- core/skills/end-to-end-testing/scripts/lint_e2e.py +88 -0
- core/skills/end-to-end-testing/versions.json +16 -0
- core/skills/frontend-design/SKILL.md +76 -0
- core/skills/frontend-design/assets/design-checklist.md +29 -0
- core/skills/frontend-design/references/design-principles.md +65 -0
- core/skills/frontend-design/scripts/check_contrast.py +89 -0
- core/skills/frontend-fundamentals/SKILL.md +213 -0
- core/skills/frontend-fundamentals/assets/frontend-checklist.md +25 -0
- core/skills/frontend-fundamentals/references/rendering-and-state.md +66 -0
- core/skills/frontend-fundamentals/scripts/check_frontend.py +86 -0
- core/skills/graph-explorer/SKILL.md +215 -0
- core/skills/graph-explorer/scripts/explain-impact.sh +64 -0
- core/skills/graphql/SKILL.md +187 -0
- core/skills/grpc-microservices/SKILL.md +174 -0
- core/skills/hexagonal-architecture/SKILL.md +199 -0
- core/skills/hexagonal-architecture/assets/folder-scaffold.md +233 -0
- core/skills/hexagonal-architecture/references/anti-patterns.md +129 -0
- core/skills/hexagonal-architecture/references/go-fiber-layout.md +429 -0
- core/skills/hexagonal-architecture/references/python-fastapi-layout.md +453 -0
- core/skills/hexagonal-architecture/references/react-native-layout.md +428 -0
- core/skills/i18n/SKILL.md +126 -0
- core/skills/incident-response/SKILL.md +225 -0
- core/skills/incident-response/assets/incident-checklist.md +29 -0
- core/skills/incident-response/references/severity-and-runbook.md +53 -0
- core/skills/incident-response/scripts/classify_severity.py +86 -0
- core/skills/linux-sysadmin/SKILL.md +115 -0
- core/skills/linux-sysadmin/assets/hardening-checklist.md +29 -0
- core/skills/linux-sysadmin/references/ssh-hardening.md +62 -0
- core/skills/linux-sysadmin/references/systemd-and-services.md +77 -0
- core/skills/linux-sysadmin/scripts/triage.sh +50 -0
- core/skills/linux-sysadmin/versions.json +17 -0
- core/skills/llm-patterns/SKILL.md +410 -0
- core/skills/llm-patterns/assets/llm-feature-checklist.md +26 -0
- core/skills/llm-patterns/references/rag-and-evals.md +59 -0
- core/skills/llm-patterns/scripts/estimate_tokens.py +75 -0
- core/skills/messaging-queues/SKILL.md +142 -0
- core/skills/mobile-fundamentals/SKILL.md +406 -0
- core/skills/mobile-fundamentals/assets/mobile-launch-checklist.md +130 -0
- core/skills/mobile-fundamentals/references/navigation-and-deep-links.md +337 -0
- core/skills/mobile-fundamentals/references/offline-sync.md +339 -0
- core/skills/node-backend/SKILL.md +114 -0
- core/skills/node-backend/assets/node-checklist.md +25 -0
- core/skills/node-backend/references/async-and-errors.md +67 -0
- core/skills/node-backend/references/event-loop.md +65 -0
- core/skills/node-backend/scripts/check_package.py +78 -0
- core/skills/node-backend/versions.json +17 -0
- core/skills/observability/SKILL.md +289 -0
- core/skills/observability/assets/observability-checklist.md +27 -0
- core/skills/observability/references/instrumentation.md +57 -0
- core/skills/observability/scripts/lint_logging.py +77 -0
- core/skills/payments/SKILL.md +102 -0
- core/skills/performance/SKILL.md +305 -0
- core/skills/performance/assets/perf-checklist.md +143 -0
- core/skills/performance/references/mobile-performance.md +249 -0
- core/skills/performance/references/web-vitals.md +209 -0
- core/skills/php/SKILL.md +116 -0
- core/skills/php/assets/php-checklist.md +26 -0
- core/skills/php/references/modern-php.md +64 -0
- core/skills/php/references/security.md +72 -0
- core/skills/php/scripts/scan_php_smells.py +99 -0
- core/skills/php/versions.json +9 -0
- core/skills/pr-mode-driver/SKILL.md +65 -0
- core/skills/realtime-websockets/SKILL.md +152 -0
- core/skills/redis/SKILL.md +105 -0
- core/skills/redis/assets/redis-checklist.md +27 -0
- core/skills/redis/references/operations.md +66 -0
- core/skills/redis/references/patterns.md +62 -0
- core/skills/redis/scripts/analyze_info.py +101 -0
- core/skills/redis/versions.json +9 -0
- core/skills/search/SKILL.md +91 -0
- core/skills/search/references/grep.md +76 -0
- core/skills/search/scripts/verify-count.sh +73 -0
- core/skills/search-infra/SKILL.md +109 -0
- core/skills/security-mobile/SKILL.md +394 -0
- core/skills/security-mobile/assets/mobile-security-checklist.md +117 -0
- core/skills/security-mobile/references/masvs-l1-checklist.md +127 -0
- core/skills/security-web/SKILL.md +217 -0
- core/skills/security-web/assets/security-web-checklist.md +167 -0
- core/skills/security-web/references/owasp-top-10.md +551 -0
- core/skills/security-web/references/supply-chain.md +179 -0
- core/skills/security-web/scripts/csp-check.sh +153 -0
- core/skills/shell-scripting/SKILL.md +128 -0
- core/skills/shell-scripting/assets/script-checklist.md +33 -0
- core/skills/shell-scripting/references/argument-parsing.md +84 -0
- core/skills/shell-scripting/references/bash-robustness.md +78 -0
- core/skills/shell-scripting/scripts/lint_script.sh +53 -0
- core/skills/shell-scripting/scripts/new_script.py +131 -0
- core/skills/shell-scripting/versions.json +23 -0
- core/skills/sql-authoring/SKILL.md +104 -0
- core/skills/sql-authoring/assets/query-review-checklist.md +26 -0
- core/skills/sql-authoring/references/query-patterns.md +89 -0
- core/skills/sql-authoring/references/reading-explain.md +52 -0
- core/skills/sql-authoring/scripts/analyze_plan.py +100 -0
- core/skills/sql-authoring/versions.json +17 -0
- core/skills/state-management/SKILL.md +428 -0
- core/skills/state-management/references/tanstack-query-recipes.md +301 -0
- core/skills/state-management/references/zustand-recipes.md +364 -0
- core/skills/supabase/SKILL.md +108 -0
- core/skills/supabase/assets/supabase-checklist.md +26 -0
- core/skills/supabase/references/realtime-and-storage.md +55 -0
- core/skills/supabase/references/rls-and-auth.md +65 -0
- core/skills/supabase/scripts/check_rls.py +88 -0
- core/skills/supabase/versions.json +9 -0
- core/skills/task-driver/SKILL.md +272 -0
- core/skills/task-driver/scripts/task-lint.sh +163 -0
- core/skills/technical-writing/SKILL.md +85 -0
- core/skills/technical-writing/assets/doc-checklist.md +29 -0
- core/skills/technical-writing/references/doc-anatomy.md +53 -0
- core/skills/technical-writing/references/writing-craft.md +59 -0
- core/skills/technical-writing/scripts/new_doc.py +81 -0
- core/skills/terraform-k8s/SKILL.md +133 -0
- core/skills/testing-strategy/SKILL.md +266 -0
- core/skills/testing-strategy/assets/test-review-checklist.md +25 -0
- core/skills/testing-strategy/references/test-types.md +52 -0
- core/skills/testing-strategy/scripts/coverage_gate.py +79 -0
- core/skills/thinking_os/SKILL.md +288 -0
- core/skills/thinking_os/scripts/classify.sh +122 -0
- core/skills/typescript/SKILL.md +110 -0
- core/skills/typescript/assets/typescript-checklist.md +24 -0
- core/skills/typescript/references/strictness.md +53 -0
- core/skills/typescript/references/type-system.md +86 -0
- core/skills/typescript/scripts/check_tsconfig.py +92 -0
- core/skills/typescript/versions.json +9 -0
- core/subsystems.yaml +202 -0
- core/thinking_os/__init__.py +1 -0
- core/thinking_os/_agent_markers.py +32 -0
- core/thinking_os/agents/README.md +71 -0
- core/thinking_os/agents/analyst.md +139 -0
- core/thinking_os/agents/architect.md +127 -0
- core/thinking_os/agents/debugger.md +111 -0
- core/thinking_os/agents/deployer.md +112 -0
- core/thinking_os/agents/distiller.md +28 -0
- core/thinking_os/agents/documenter.md +101 -0
- core/thinking_os/agents/implementer.md +135 -0
- core/thinking_os/agents/internal/session_observer.md +39 -0
- core/thinking_os/agents/observer.md +100 -0
- core/thinking_os/agents/onboarder.md +81 -0
- core/thinking_os/agents/refactorer.md +123 -0
- core/thinking_os/agents/repairer.md +47 -0
- core/thinking_os/agents/researcher.md +129 -0
- core/thinking_os/agents/reviewer.md +147 -0
- core/thinking_os/agents/security_auditor.md +133 -0
- core/thinking_os/background.py +405 -0
- core/thinking_os/bootstrap_outcomes.py +200 -0
- core/thinking_os/budget.py +302 -0
- core/thinking_os/capture.py +495 -0
- core/thinking_os/cognition.py +516 -0
- core/thinking_os/cognition_schemas.py +517 -0
- core/thinking_os/compress.py +192 -0
- core/thinking_os/concepts.py +233 -0
- core/thinking_os/dashboard.py +159 -0
- core/thinking_os/database.py +2883 -0
- core/thinking_os/decay.py +393 -0
- core/thinking_os/digest.py +295 -0
- core/thinking_os/dispatcher.py +192 -0
- core/thinking_os/dispatcher_helpers.py +48 -0
- core/thinking_os/dispatchers/__init__.py +3 -0
- core/thinking_os/dispatchers/default.py +47 -0
- core/thinking_os/distill.py +192 -0
- core/thinking_os/doc_indexer.py +905 -0
- core/thinking_os/embeddings.py +943 -0
- core/thinking_os/formula_composer.py +556 -0
- core/thinking_os/gate_marker.py +75 -0
- core/thinking_os/graph.py +296 -0
- core/thinking_os/graph_indexer.py +360 -0
- core/thinking_os/health_check.py +517 -0
- core/thinking_os/impact.py +119 -0
- core/thinking_os/memory_gc.py +356 -0
- core/thinking_os/migrator_embeddings.py +318 -0
- core/thinking_os/precision.py +194 -0
- core/thinking_os/presets/registry.yaml +127 -0
- core/thinking_os/record_outcome.py +399 -0
- core/thinking_os/repair.py +105 -0
- core/thinking_os/retrieval_quality.py +239 -0
- core/thinking_os/roles/analyst.yaml +83 -0
- core/thinking_os/roles/architect.yaml +88 -0
- core/thinking_os/roles/debugger.yaml +71 -0
- core/thinking_os/roles/deployer.yaml +71 -0
- core/thinking_os/roles/documenter.yaml +72 -0
- core/thinking_os/roles/implementer.yaml +83 -0
- core/thinking_os/roles/observer.yaml +70 -0
- core/thinking_os/roles/refactorer.yaml +71 -0
- core/thinking_os/roles/researcher.yaml +72 -0
- core/thinking_os/roles/reviewer.yaml +79 -0
- core/thinking_os/roles/security_auditor.yaml +83 -0
- core/thinking_os/roles_state.py +168 -0
- core/thinking_os/sanitizer.py +320 -0
- core/thinking_os/server.py +3160 -0
- core/thinking_os/session_enrich.py +272 -0
- core/thinking_os/session_observe_worker.py +111 -0
- core/thinking_os/session_startup.py +74 -0
- core/thinking_os/session_summary.py +245 -0
- core/thinking_os/situations/registry.yaml +99 -0
- core/thinking_os/task_analyzer.py +462 -0
- core/thinking_os/task_parser.py +342 -0
- core/thinking_os/task_sync.py +73 -0
- core/thinking_os/tools/__init__.py +6 -0
- core/thinking_os/tools/_shared.py +947 -0
- core/thinking_os/tools/cognition.py +1867 -0
- core/thinking_os/tools/docs.py +770 -0
- core/thinking_os/tools/learning.py +2078 -0
- core/thinking_os/tools/logs.py +79 -0
- core/thinking_os/tools/memory.py +840 -0
- core/thinking_os/tools/metrics.py +200 -0
- core/thinking_os/tools/retrieve.py +415 -0
- core/thinking_os/tools/routing.py +658 -0
- core/thinking_os/tools/tasks.py +449 -0
- core/thinking_os/tools/trajectory.py +181 -0
- core/thinking_os/tracing.py +235 -0
- core/web/__init__.py +5 -0
- core/web/_cache.py +118 -0
- core/web/_deps.py +56 -0
- core/web/_envelope.py +85 -0
- core/web/_project_context.py +140 -0
- core/web/chat_providers.py +108 -0
- core/web/init_jobs.py +216 -0
- core/web/routes/__init__.py +25 -0
- core/web/routes/_bounded_read.py +76 -0
- core/web/routes/board.py +1089 -0
- core/web/routes/cognition.py +1838 -0
- core/web/routes/config.py +635 -0
- core/web/routes/graph.py +513 -0
- core/web/routes/health.py +180 -0
- core/web/routes/hooks.py +288 -0
- core/web/routes/hub.py +1219 -0
- core/web/routes/logs.py +374 -0
- core/web/routes/metrics.py +43 -0
- core/web/routes/observability.py +400 -0
- core/web/routes/patterns.py +227 -0
- core/web/routes/presence.py +609 -0
- core/web/routes/roles.py +446 -0
- core/web/routes/scheduled.py +261 -0
- core/web/routes/search.py +238 -0
- core/web/routes/sessions.py +220 -0
- core/web/routes/settings.py +363 -0
- core/web/routes/stream.py +547 -0
- core/web/security.py +157 -0
- core/web/server.py +283 -0
- graph_os/__init__.py +29 -0
- graph_os/backend.py +233 -0
- graph_os/backends/__init__.py +13 -0
- graph_os/backends/sqlite_backend.py +1053 -0
- graph_os/communities.py +410 -0
- graph_os/enterprise.py +218 -0
- graph_os/entry_points.py +226 -0
- graph_os/extractors/__init__.py +25 -0
- graph_os/extractors/code_generic.py +914 -0
- graph_os/extractors/code_go.py +1422 -0
- graph_os/extractors/code_json.py +340 -0
- graph_os/extractors/code_php.py +979 -0
- graph_os/extractors/code_python.py +1454 -0
- graph_os/extractors/code_shell.py +538 -0
- graph_os/extractors/code_toml.py +302 -0
- graph_os/extractors/code_ts.py +1665 -0
- graph_os/extractors/code_yaml.py +394 -0
- graph_os/extractors/contracts.py +1592 -0
- graph_os/extractors/md_links.py +890 -0
- graph_os/extractors/task_deps.py +345 -0
- graph_os/groups/__init__.py +22 -0
- graph_os/groups/cross_repo.py +156 -0
- graph_os/groups/manifest.py +141 -0
- graph_os/ingest/__init__.py +19 -0
- graph_os/ingest/base.py +306 -0
- graph_os/ingest/github.py +112 -0
- graph_os/ingest/zip.py +95 -0
- graph_os/toolchain.py +393 -0
- graph_os/tools/__init__.py +9 -0
- graph_os/tools/graph.py +5573 -0
- graph_os/tools/reindex_dispatch.py +730 -0
- graph_os/tree_sitter_overlay.py +235 -0
- graph_os/types.py +252 -0
- graph_os/vec_index.py +277 -0
- graph_os/viewer/__init__.py +12 -0
- graph_os/viewer/exporter.py +93 -0
- graph_os/viewer/template.py +189 -0
- scheduled/__init__.py +0 -0
- scheduled/_activity.py +126 -0
- scheduled/_state.py +113 -0
- scheduled/config.py +86 -0
- scheduled/dep_reconcile.py +135 -0
- scheduled/error_sweep.py +137 -0
- scheduled/nightly.py +930 -0
- scheduled/responsive_extract.py +65 -0
- scripts/__init__.py +4 -0
- scripts/_commit_msg_body.sh +31 -0
- scripts/_post_commit_body.sh +49 -0
- scripts/_pre_commit_body.sh +121 -0
- scripts/_prepare_commit_msg_body.sh +53 -0
- scripts/audit_mcp_tools.py +693 -0
- scripts/bench_sdk_dispatcher.py +177 -0
- scripts/capture_golden.py +169 -0
- scripts/check_graph_phantoms.py +75 -0
- scripts/dev/audit_doc_links.py +359 -0
- scripts/dev/audit_scaffold_module_tags.py +107 -0
- scripts/dev/backfill_doc_headers.py +328 -0
- scripts/dev/backfill_nav_lines.py +119 -0
- scripts/dev/fix_nav_placement.py +106 -0
- scripts/dev/inspect_sdk_options.py +45 -0
- scripts/dev/migrate_check_ids.py +170 -0
- scripts/dev/strip_purpose_blocks.py +154 -0
- scripts/dump_openapi.py +66 -0
- scripts/e2e_dispatch_tool.py +195 -0
- scripts/generate_manifest.py +166 -0
- scripts/golden_sections.py +20 -0
- scripts/graph_demo.py +161 -0
- scripts/install-git-hooks.sh +47 -0
- scripts/migrate_embeddings_minilm_to_bge_m3.py +84 -0
- scripts/operational_eval.py +445 -0
- scripts/probe_agent_session_resolver.py +59 -0
- scripts/prune_deleted_path.py +127 -0
- scripts/refactor_agent_dual_mode.py +171 -0
- scripts/refresh_skill_versions.py +302 -0
- scripts/regen_doc_index.py +209 -0
- scripts/regen_doctor_schema.py +62 -0
- scripts/regen_rules.py +94 -0
- scripts/rename_formulas_to_semantic.py +241 -0
- scripts/smoke_db_connections.py +183 -0
- scripts/smoke_doc_header.py +72 -0
- scripts/smoke_graph_e2e.py +374 -0
- scripts/smoke_sdk_dispatch.py +84 -0
- scripts/smoke_uid_resolver.py +164 -0
- scripts/verify_dispatchers.py +244 -0
- scripts/verify_phase_c_e2e.py +436 -0
- templates/__init__.py +6 -0
- templates/_base/Makefile.base +353 -0
- templates/_base/base.yaml +59 -0
- templates/_base/coding-os.yaml.template +39 -0
- templates/_base/dimension-registry.template.md +68 -0
- templates/_base/domain-config.template.json +46 -0
- templates/_base/fragments/anatomy-map.md.tmpl +11 -0
- templates/_base/fragments/context-discipline.md.tmpl +3 -0
- templates/_base/fragments/core-loop.md.tmpl +52 -0
- templates/_base/fragments/engineering-routing.md.tmpl +3 -0
- templates/_base/fragments/header.md.tmpl +6 -0
- templates/_base/fragments/identity.md.tmpl +3 -0
- templates/_base/fragments/principles.md.tmpl +3 -0
- templates/_base/fragments/retrieval-routing.md.tmpl +23 -0
- templates/_base/fragments/session-handoff.md.tmpl +3 -0
- templates/_base/fragments/skills.md.tmpl +3 -0
- templates/_base/fragments/ssot-map.md.tmpl +3 -0
- templates/_base/fragments/stop-conditions.md.tmpl +3 -0
- templates/_base/fragments/subagent-dispatch.md.tmpl +3 -0
- templates/_base/fragments/task-authoring.md.tmpl +69 -0
- templates/_base/fragments/task-logging.md.tmpl +8 -0
- templates/_base/fragments/tool-routing.md.tmpl +9 -0
- templates/_base/fragments/verification-matrix.md.tmpl +12 -0
- templates/_base/lang/dart/analysis_options.yaml +7 -0
- templates/_base/lang/php/phpcs.xml.dist +10 -0
- templates/_base/lang/python/pyproject.toml +19 -0
- templates/_base/lang/rust/clippy.toml +5 -0
- templates/_base/lang/rust/rustfmt.toml +3 -0
- templates/_base/lang/typescript/eslint.config.js +26 -0
- templates/_base/lang/typescript/tsconfig.json +15 -0
- templates/_base/lang/typescript/vitest.config.ts +10 -0
- templates/_base/scaffold/changes.log +1 -0
- templates/_base/scaffold/docs/00-index.md +55 -0
- templates/_base/scaffold/docs/_meta/feature-dependency-tree.md +30 -0
- templates/_base/scaffold/docs/_meta/foundation-map.md +56 -0
- templates/_base/scaffold/docs/_meta/questions.md +8 -0
- templates/_base/scaffold/docs/_meta/roadmap.md +33 -0
- templates/_base/scaffold/docs/api-contracts/00-index.md +58 -0
- templates/_base/scaffold/docs/api-contracts/error-format.md +58 -0
- templates/_base/scaffold/docs/architecture/00-index.md +42 -0
- templates/_base/scaffold/docs/architecture/adr/00-index.md +39 -0
- templates/_base/scaffold/docs/engineering/00-index.md +9 -0
- templates/_base/scaffold/docs/governance/00-index.md +55 -0
- templates/_base/scaffold/docs/governance/_templates/doc-cheat-sheet.md +202 -0
- templates/_base/scaffold/docs/governance/_templates/playbook-template.md +81 -0
- templates/_base/scaffold/docs/governance/_templates/post-mortem-template.md +85 -0
- templates/_base/scaffold/docs/governance/_templates/runbook-template.md +88 -0
- templates/_base/scaffold/docs/governance/_templates/security-review-template.md +111 -0
- templates/_base/scaffold/docs/governance/_templates/task-detail.md +61 -0
- templates/_base/scaffold/docs/governance/agent-workflow.md +101 -0
- templates/_base/scaffold/docs/governance/anatomy-contract.md +150 -0
- templates/_base/scaffold/docs/governance/critical-rules.md +224 -0
- templates/_base/scaffold/docs/governance/decision-records.md +58 -0
- templates/_base/scaffold/docs/governance/docs-first-protocol.md +157 -0
- templates/_base/scaffold/docs/governance/docs-system.md +151 -0
- templates/_base/scaffold/docs/governance/gdpr-compliance.md +66 -0
- templates/_base/scaffold/docs/governance/mcp-tool-inventory.md +112 -0
- templates/_base/scaffold/docs/governance/risk-register.md +26 -0
- templates/_base/scaffold/docs/governance/scaffold-boundary-contract.md +161 -0
- templates/_base/scaffold/docs/governance/task-lifecycle.md +125 -0
- templates/_base/scaffold/docs/governance/wrapper-derivation.md +50 -0
- templates/_base/scaffold/docs/insights/00-index.md +17 -0
- templates/_base/scaffold/docs/ops/00-index.md +59 -0
- templates/_base/scaffold/docs/ops/runbooks/00-index.md +9 -0
- templates/_base/scaffold/docs/playbooks/00-index.md +12 -0
- templates/_base/scaffold/docs/playbooks/research-validation.md +29 -0
- templates/_base/scaffold/docs/playbooks/security-review.md +41 -0
- templates/_base/scaffold/docs/prd/00-index.md +43 -0
- templates/_base/scaffold/docs/prd/01-snapshot-vision.md +56 -0
- templates/_base/scaffold/docs/workflow/workflow-guide.md +138 -0
- templates/_base/scaffold/src/shared/README.md +23 -0
- templates/_base/skill-enforcement.template.md +14 -0
- templates/_base/task-detail.template.md +61 -0
- templates/_presets/ai-saas.yaml +9 -0
- templates/_presets/django-next.yaml +8 -0
- templates/_presets/dotnet-react.yaml +8 -0
- templates/_presets/flutter-baas.yaml +8 -0
- templates/_presets/go-react.yaml +8 -0
- templates/_presets/hexagonal-product.yaml +16 -0
- templates/_presets/jamstack.yaml +8 -0
- templates/_presets/laravel-vue.yaml +8 -0
- templates/_presets/mean.yaml +8 -0
- templates/_presets/mern.yaml +9 -0
- templates/_presets/nest-angular.yaml +8 -0
- templates/_presets/nextjs-fastapi.yaml +10 -0
- templates/_presets/nuxt-fullstack.yaml +9 -0
- templates/_presets/pern.yaml +9 -0
- templates/_presets/rails-react.yaml +8 -0
- templates/_presets/rn-api.yaml +8 -0
- templates/_presets/rust-svelte.yaml +8 -0
- templates/_presets/spring-react.yaml +8 -0
- templates/_presets/t3-style.yaml +9 -0
- templates/_presets/tall.yaml +8 -0
- templates/_presets/wordpress-cms.yaml +8 -0
- templates/angular/rules/frontend.md +19 -0
- templates/angular/scaffold/docs/engineering/accessibility.md +46 -0
- templates/angular/scaffold/docs/engineering/angular-rules.md +35 -0
- templates/angular/scaffold/docs/playbooks/angular-app.md +42 -0
- templates/angular/scaffold/src/frontend/angular.json +52 -0
- templates/angular/scaffold/src/frontend/package.json +28 -0
- templates/angular/scaffold/src/frontend/src/app/app.component.ts +19 -0
- templates/angular/scaffold/src/frontend/src/app/app.config.ts +22 -0
- templates/angular/scaffold/src/frontend/src/app/app.routes.ts +8 -0
- templates/angular/scaffold/src/frontend/src/app/core/global-error-handler.ts +12 -0
- templates/angular/scaffold/src/frontend/src/app/health/health.component.ts +14 -0
- templates/angular/scaffold/src/frontend/src/app/health/health.service.spec.ts +16 -0
- templates/angular/scaffold/src/frontend/src/app/health/health.service.ts +10 -0
- templates/angular/scaffold/src/frontend/src/index.html +11 -0
- templates/angular/scaffold/src/frontend/src/main.ts +9 -0
- templates/angular/scaffold/src/frontend/src/styles.css +14 -0
- templates/angular/scaffold/src/frontend/tsconfig.app.json +8 -0
- templates/angular/scaffold/src/frontend/tsconfig.json +27 -0
- templates/angular/scaffold/src/frontend/tsconfig.spec.json +8 -0
- templates/angular/scaffold-boundary.yaml +27 -0
- templates/angular/skills/angular/SKILL.md +79 -0
- templates/angular/skills/angular/references/anatomy.md +69 -0
- templates/angular/stack.yaml +75 -0
- templates/aspnet-core/rules/backend.md +20 -0
- templates/aspnet-core/scaffold/docs/engineering/aspnet-core-rules.md +36 -0
- templates/aspnet-core/scaffold/docs/playbooks/aspnet-core-service.md +40 -0
- templates/aspnet-core/scaffold/src/backend/Backend.csproj +11 -0
- templates/aspnet-core/scaffold/src/backend/Backend.sln +27 -0
- templates/aspnet-core/scaffold/src/backend/Common/ExceptionHandlingMiddleware.cs +35 -0
- templates/aspnet-core/scaffold/src/backend/Features/Health/HealthEndpoints.cs +10 -0
- templates/aspnet-core/scaffold/src/backend/Features/Health/HealthService.cs +9 -0
- templates/aspnet-core/scaffold/src/backend/Program.cs +22 -0
- templates/aspnet-core/scaffold/src/backend/tests/Backend.Tests/Backend.Tests.csproj +22 -0
- templates/aspnet-core/scaffold/src/backend/tests/Backend.Tests/HealthServiceTests.cs +15 -0
- templates/aspnet-core/scaffold-boundary.yaml +24 -0
- templates/aspnet-core/skills/aspnet-core/SKILL.md +80 -0
- templates/aspnet-core/skills/aspnet-core/references/anatomy.md +65 -0
- templates/aspnet-core/stack.yaml +68 -0
- templates/astro/rules/frontend.md +20 -0
- templates/astro/scaffold/docs/engineering/astro-rules.md +40 -0
- templates/astro/scaffold/docs/playbooks/astro-app.md +57 -0
- templates/astro/scaffold/docs/playbooks/content-seo.md +37 -0
- templates/astro/scaffold/src/frontend/astro.config.mjs +10 -0
- templates/astro/scaffold/src/frontend/package.json +23 -0
- templates/astro/scaffold/src/frontend/src/components/Greeting.astro +13 -0
- templates/astro/scaffold/src/frontend/src/content/posts/hello.md +10 -0
- templates/astro/scaffold/src/frontend/src/content.config.ts +19 -0
- templates/astro/scaffold/src/frontend/src/lib/problem.test.ts +39 -0
- templates/astro/scaffold/src/frontend/src/lib/problem.ts +30 -0
- templates/astro/scaffold/src/frontend/src/pages/api/health.ts +13 -0
- templates/astro/scaffold/src/frontend/src/pages/index.astro +21 -0
- templates/astro/scaffold/src/frontend/tsconfig.json +9 -0
- templates/astro/scaffold/src/frontend/vitest.config.ts +10 -0
- templates/astro/scaffold-boundary.yaml +28 -0
- templates/astro/skills/astro/SKILL.md +71 -0
- templates/astro/skills/astro/references/anatomy.md +66 -0
- templates/astro/stack.yaml +75 -0
- templates/csharp-plain/scaffold/src/backend/Backend.csproj +12 -0
- templates/csharp-plain/scaffold/src/backend/Program.cs +1 -0
- templates/csharp-plain/scaffold-boundary.yaml +23 -0
- templates/csharp-plain/stack.yaml +50 -0
- templates/django/rules/backend.md +18 -0
- templates/django/scaffold/docs/engineering/anti-ambiguity.md +74 -0
- templates/django/scaffold/docs/engineering/backend-rules.md +133 -0
- templates/django/scaffold/docs/engineering/glossary.md +51 -0
- templates/django/scaffold/docs/engineering/logging-standards.md +107 -0
- templates/django/scaffold/docs/engineering/naming-conventions.md +68 -0
- templates/django/scaffold/docs/engineering/secrets-rotation-runbook.md +142 -0
- templates/django/scaffold/docs/playbooks/backend-api.md +119 -0
- templates/django/scaffold/src/backend/config/__init__.py +0 -0
- templates/django/scaffold/src/backend/config/settings.py +31 -0
- templates/django/scaffold/src/backend/config/urls.py +11 -0
- templates/django/scaffold/src/backend/config/wsgi.py +6 -0
- templates/django/scaffold/src/backend/manage.py +14 -0
- templates/django/scaffold/src/backend/pyproject.toml +37 -0
- templates/django/scaffold/src/backend/tests/test_health.py +4 -0
- templates/django/scaffold-boundary.yaml +25 -0
- templates/django/skills/python-django/SKILL.md +450 -0
- templates/django/skills/python-django/references/anatomy.md +117 -0
- templates/django/skills/python-django/scripts/new_endpoint.py +89 -0
- templates/django/stack.yaml +73 -0
- templates/fastapi/rules/backend.md +18 -0
- templates/fastapi/scaffold/docs/engineering/fastapi-rules.md +37 -0
- templates/fastapi/scaffold/docs/playbooks/fastapi-service.md +30 -0
- templates/fastapi/scaffold/src/backend/app/__init__.py +0 -0
- templates/fastapi/scaffold/src/backend/app/main.py +8 -0
- templates/fastapi/scaffold/src/backend/pyproject.toml +39 -0
- templates/fastapi/scaffold/src/backend/tests/test_health.py +11 -0
- templates/fastapi/scaffold-boundary.yaml +25 -0
- templates/fastapi/skills/python-fastapi/SKILL.md +75 -0
- templates/fastapi/skills/python-fastapi/references/anatomy.md +117 -0
- templates/fastapi/skills/python-fastapi/scripts/new_endpoint.py +101 -0
- templates/fastapi/stack.yaml +57 -0
- templates/flutter/rules/mobile.md +26 -0
- templates/flutter/scaffold/docs/engineering/flutter-rules.md +35 -0
- templates/flutter/scaffold/docs/playbooks/flutter-app.md +45 -0
- templates/flutter/scaffold/src/mobile/lib/core/error_mapper.dart +17 -0
- templates/flutter/scaffold/src/mobile/lib/core/router.dart +13 -0
- templates/flutter/scaffold/src/mobile/lib/main.dart +22 -0
- templates/flutter/scaffold/src/mobile/lib/screens/health_screen.dart +32 -0
- templates/flutter/scaffold/src/mobile/lib/services/health_service.dart +11 -0
- templates/flutter/scaffold/src/mobile/lib/state/health_provider.dart +13 -0
- templates/flutter/scaffold/src/mobile/pubspec.yaml +22 -0
- templates/flutter/scaffold/src/mobile/test/health_provider_test.dart +90 -0
- templates/flutter/scaffold-boundary.yaml +28 -0
- templates/flutter/skills/flutter/SKILL.md +76 -0
- templates/flutter/skills/flutter/references/anatomy.md +64 -0
- templates/flutter/stack.yaml +70 -0
- templates/go/rules/backend.md +19 -0
- templates/go/scaffold/docs/engineering/go-rules.md +45 -0
- templates/go/scaffold/docs/playbooks/go-service.md +30 -0
- templates/go/scaffold/src/backend/cmd/api/main.go +22 -0
- templates/go/scaffold/src/backend/cmd/api/main_test.go +20 -0
- templates/go/scaffold/src/backend/go.mod +3 -0
- templates/go/scaffold-boundary.yaml +25 -0
- templates/go/skills/go-patterns/SKILL.md +68 -0
- templates/go/skills/go-patterns/assets/go-checklist.md +29 -0
- templates/go/skills/go-patterns/references/anatomy.md +115 -0
- templates/go/skills/go-patterns/references/go-2026-idioms.md +92 -0
- templates/go/skills/go-patterns/scripts/new_endpoint.py +117 -0
- templates/go/skills/go-patterns/versions.json +16 -0
- templates/go/stack.yaml +54 -0
- templates/go-fiber/rules/backend.md +20 -0
- templates/go-fiber/scaffold/docs/engineering/fiber-rules.md +97 -0
- templates/go-fiber/scaffold/docs/playbooks/fiber-service.md +149 -0
- templates/go-fiber/scaffold/src/backend/cmd/api/main.go +21 -0
- templates/go-fiber/scaffold/src/backend/cmd/api/main_test.go +17 -0
- templates/go-fiber/scaffold/src/backend/go.mod +23 -0
- templates/go-fiber/scaffold/src/backend/go.sum +49 -0
- templates/go-fiber/scaffold-boundary.yaml +26 -0
- templates/go-fiber/skills/go-fiber/SKILL.md +203 -0
- templates/go-fiber/skills/go-fiber/assets/fiber-checklist.md +26 -0
- templates/go-fiber/skills/go-fiber/references/anatomy.md +116 -0
- templates/go-fiber/skills/go-fiber/references/fiber-v3-patterns.md +89 -0
- templates/go-fiber/skills/go-fiber/scripts/new_endpoint.py +107 -0
- templates/go-fiber/skills/go-fiber/versions.json +16 -0
- templates/go-fiber/stack.yaml +59 -0
- templates/go-plain/scaffold/src/backend/go.mod +3 -0
- templates/go-plain/scaffold/src/backend/main.go +7 -0
- templates/go-plain/scaffold-boundary.yaml +22 -0
- templates/go-plain/stack.yaml +51 -0
- templates/java-plain/scaffold/src/backend/mvnw +302 -0
- templates/java-plain/scaffold/src/backend/pom.xml +41 -0
- templates/java-plain/scaffold/src/backend/src/main/java/com/example/app/Main.java +10 -0
- templates/java-plain/scaffold-boundary.yaml +23 -0
- templates/java-plain/stack.yaml +51 -0
- templates/laravel/rules/backend.md +20 -0
- templates/laravel/scaffold/docs/engineering/laravel-rules.md +28 -0
- templates/laravel/scaffold/docs/playbooks/laravel-service.md +28 -0
- templates/laravel/scaffold/src/backend/app/Exceptions/Handler.php +27 -0
- templates/laravel/scaffold/src/backend/app/Http/Controllers/HealthController.php +15 -0
- templates/laravel/scaffold/src/backend/app/Support/HealthStatus.php +14 -0
- templates/laravel/scaffold/src/backend/composer.json +24 -0
- templates/laravel/scaffold/src/backend/phpunit.xml +10 -0
- templates/laravel/scaffold/src/backend/public/index.php +8 -0
- templates/laravel/scaffold/src/backend/routes/api.php +7 -0
- templates/laravel/scaffold/src/backend/tests/Unit/HealthStatusTest.php +16 -0
- templates/laravel/scaffold-boundary.yaml +23 -0
- templates/laravel/skills/laravel/SKILL.md +56 -0
- templates/laravel/skills/laravel/references/anatomy.md +65 -0
- templates/laravel/stack.yaml +66 -0
- templates/meta/rules/graph-first.md +27 -0
- templates/meta/rules/hook-author.md +19 -0
- templates/meta/rules/mcp-tool-author.md +18 -0
- templates/meta/rules/meta-engineering.md +17 -0
- templates/meta/scaffold-boundary.yaml +55 -0
- templates/meta/skills/claude-sdk-integration/SKILL.md +163 -0
- templates/meta/skills/claude-sdk-integration/assets/sdk-checklist.md +27 -0
- templates/meta/skills/claude-sdk-integration/scripts/check_model_ids.py +97 -0
- templates/meta/skills/graph-os-authoring/SKILL.md +278 -0
- templates/meta/skills/graph-os-authoring/assets/graph-os-checklist.md +25 -0
- templates/meta/skills/graph-os-authoring/scripts/new_extractor.py +76 -0
- templates/meta/skills/hook-authoring/SKILL.md +292 -0
- templates/meta/skills/hook-authoring/assets/hook-checklist.md +30 -0
- templates/meta/skills/hook-authoring/scripts/new_hook.sh +75 -0
- templates/meta/skills/mcp-tool-authoring/SKILL.md +301 -0
- templates/meta/skills/mcp-tool-authoring/assets/mcp-tool-checklist.md +29 -0
- templates/meta/skills/mcp-tool-authoring/scripts/new_tool.py +74 -0
- templates/meta/skills/meta-engineering/SKILL.md +151 -0
- templates/meta/skills/meta-engineering/assets/meta-edit-checklist.md +28 -0
- templates/meta/skills/meta-engineering/scripts/which_layer.py +61 -0
- templates/meta/skills/python-meta-server/SKILL.md +162 -0
- templates/meta/skills/python-meta-server/assets/meta-server-checklist.md +28 -0
- templates/meta/skills/python-meta-server/scripts/check_envelope.py +91 -0
- templates/meta/skills/react-vite-hub/SKILL.md +140 -0
- templates/meta/skills/react-vite-hub/assets/hub-ui-checklist.md +23 -0
- templates/meta/skills/react-vite-hub/scripts/check_vite_env.py +73 -0
- templates/meta/stack.yaml +119 -0
- templates/nestjs/rules/backend.md +20 -0
- templates/nestjs/scaffold/docs/engineering/nestjs-rules.md +33 -0
- templates/nestjs/scaffold/docs/playbooks/nestjs-service.md +39 -0
- templates/nestjs/scaffold/src/backend/nest-cli.json +5 -0
- templates/nestjs/scaffold/src/backend/package.json +28 -0
- templates/nestjs/scaffold/src/backend/src/app.module.ts +9 -0
- templates/nestjs/scaffold/src/backend/src/common/all-exceptions.filter.ts +59 -0
- templates/nestjs/scaffold/src/backend/src/health/health.controller.ts +14 -0
- templates/nestjs/scaffold/src/backend/src/health/health.module.ts +10 -0
- templates/nestjs/scaffold/src/backend/src/health/health.service.spec.ts +21 -0
- templates/nestjs/scaffold/src/backend/src/health/health.service.ts +9 -0
- templates/nestjs/scaffold/src/backend/src/main.ts +26 -0
- templates/nestjs/scaffold/src/backend/tsconfig.json +16 -0
- templates/nestjs/scaffold/src/backend/vitest.config.ts +9 -0
- templates/nestjs/scaffold-boundary.yaml +25 -0
- templates/nestjs/skills/nestjs/SKILL.md +67 -0
- templates/nestjs/skills/nestjs/references/anatomy.md +65 -0
- templates/nestjs/stack.yaml +68 -0
- templates/nextjs/rules/frontend.md +18 -0
- templates/nextjs/scaffold/docs/design/00-index.md +23 -0
- templates/nextjs/scaffold/docs/design/colors-tokens.md +141 -0
- templates/nextjs/scaffold/docs/design/components-patterns.md +159 -0
- templates/nextjs/scaffold/docs/design/motion-accessibility.md +137 -0
- templates/nextjs/scaffold/docs/design/typography-spacing.md +107 -0
- templates/nextjs/scaffold/docs/engineering/accessibility-web.md +56 -0
- templates/nextjs/scaffold/docs/engineering/copywriting-standard.md +102 -0
- templates/nextjs/scaffold/docs/engineering/formatting-rules.md +89 -0
- templates/nextjs/scaffold/docs/engineering/frontend-rendering-rules.md +80 -0
- templates/nextjs/scaffold/docs/engineering/frontend-rules.md +183 -0
- templates/nextjs/scaffold/docs/engineering/i18n-policy.md +99 -0
- templates/nextjs/scaffold/docs/pages-content-spec/00-index.md +78 -0
- templates/nextjs/scaffold/docs/playbooks/content-seo.md +55 -0
- templates/nextjs/scaffold/docs/playbooks/docs-governance.md +51 -0
- templates/nextjs/scaffold/docs/playbooks/frontend-ui.md +63 -0
- templates/nextjs/scaffold/src/frontend/app/layout.tsx +14 -0
- templates/nextjs/scaffold/src/frontend/app/page.tsx +3 -0
- templates/nextjs/scaffold/src/frontend/eslint.config.js +17 -0
- templates/nextjs/scaffold/src/frontend/lib/greeting.test.ts +9 -0
- templates/nextjs/scaffold/src/frontend/lib/greeting.ts +3 -0
- templates/nextjs/scaffold/src/frontend/package.json +28 -0
- templates/nextjs/scaffold/src/frontend/tsconfig.json +18 -0
- templates/nextjs/scaffold/src/frontend/vitest.config.ts +10 -0
- templates/nextjs/scaffold-boundary.yaml +30 -0
- templates/nextjs/skills/nextjs-react/SKILL.md +485 -0
- templates/nextjs/skills/nextjs-react/references/anatomy.md +116 -0
- templates/nextjs/skills/nextjs-react/scripts/new_component.py +72 -0
- templates/nextjs/stack.yaml +81 -0
- templates/node-express/rules/backend.md +20 -0
- templates/node-express/scaffold/docs/engineering/express-rules.md +30 -0
- templates/node-express/scaffold/docs/playbooks/express-service.md +35 -0
- templates/node-express/scaffold/src/backend/package.json +24 -0
- templates/node-express/scaffold/src/backend/src/index.ts +17 -0
- templates/node-express/scaffold/src/backend/src/middleware/error-handler.ts +12 -0
- templates/node-express/scaffold/src/backend/src/routes/health.test.ts +33 -0
- templates/node-express/scaffold/src/backend/src/routes/health.ts +7 -0
- templates/node-express/scaffold/src/backend/tsconfig.json +15 -0
- templates/node-express/scaffold/src/backend/types/express-bootstrap.d.ts +21 -0
- templates/node-express/scaffold-boundary.yaml +25 -0
- templates/node-express/skills/node-express/SKILL.md +70 -0
- templates/node-express/skills/node-express/references/anatomy.md +63 -0
- templates/node-express/stack.yaml +63 -0
- templates/python/scaffold/docs/engineering/python-rules.md +27 -0
- templates/python/scaffold/docs/playbooks/python-library.md +35 -0
- templates/python/stack.yaml +60 -0
- templates/rails/rules/backend.md +10 -0
- templates/rails/scaffold/docs/engineering/rails-rules.md +33 -0
- templates/rails/scaffold/docs/playbooks/rails-service.md +42 -0
- templates/rails/scaffold/src/backend/Gemfile +12 -0
- templates/rails/scaffold/src/backend/app/controllers/application_controller.rb +26 -0
- templates/rails/scaffold/src/backend/app/controllers/health_controller.rb +6 -0
- templates/rails/scaffold/src/backend/app/models/health.rb +6 -0
- templates/rails/scaffold/src/backend/config/application.rb +12 -0
- templates/rails/scaffold/src/backend/config/boot.rb +3 -0
- templates/rails/scaffold/src/backend/config/routes.rb +4 -0
- templates/rails/scaffold/src/backend/config.ru +5 -0
- templates/rails/scaffold/src/backend/spec/rails_helper.rb +18 -0
- templates/rails/scaffold/src/backend/spec/requests/health_spec.rb +24 -0
- templates/rails/scaffold-boundary.yaml +25 -0
- templates/rails/skills/rails/SKILL.md +62 -0
- templates/rails/skills/rails/references/anatomy.md +71 -0
- templates/rails/stack.yaml +72 -0
- templates/react-native/rules/mobile.md +26 -0
- templates/react-native/scaffold/docs/engineering/accessibility-mobile.md +95 -0
- templates/react-native/scaffold/docs/engineering/mobile-rules.md +56 -0
- templates/react-native/scaffold/docs/engineering/offline-first.md +61 -0
- templates/react-native/scaffold/docs/playbooks/mobile-app.md +49 -0
- templates/react-native/scaffold/src/mobile/App.tsx +9 -0
- templates/react-native/scaffold/src/mobile/eslint.config.js +17 -0
- templates/react-native/scaffold/src/mobile/package.json +23 -0
- templates/react-native/scaffold/src/mobile/src/greeting.test.ts +9 -0
- templates/react-native/scaffold/src/mobile/src/greeting.ts +3 -0
- templates/react-native/scaffold/src/mobile/tsconfig.json +17 -0
- templates/react-native/scaffold/src/mobile/vitest.config.ts +10 -0
- templates/react-native/scaffold-boundary.yaml +30 -0
- templates/react-native/skills/react-native-mobile/SKILL.md +119 -0
- templates/react-native/skills/react-native-mobile/assets/rn-mobile-checklist.md +28 -0
- templates/react-native/skills/react-native-mobile/references/anatomy.md +140 -0
- templates/react-native/skills/react-native-mobile/references/rn-2026-practices.md +54 -0
- templates/react-native/skills/react-native-mobile/scripts/new_screen.py +73 -0
- templates/react-native/skills/react-native-mobile/versions.json +16 -0
- templates/react-native/skills/react-native-patterns/SKILL.md +512 -0
- templates/react-native/skills/react-native-patterns/assets/rn-review-checklist.md +26 -0
- templates/react-native/skills/react-native-patterns/references/anatomy.md +62 -0
- templates/react-native/skills/react-native-patterns/references/list-performance.md +70 -0
- templates/react-native/skills/react-native-patterns/scripts/scan_rn_perf.py +77 -0
- templates/react-native/stack.yaml +70 -0
- templates/ruby-plain/scaffold/src/backend/Gemfile +8 -0
- templates/ruby-plain/scaffold/src/backend/main.rb +3 -0
- templates/ruby-plain/scaffold-boundary.yaml +23 -0
- templates/ruby-plain/stack.yaml +50 -0
- templates/rust-axum/rules/backend.md +20 -0
- templates/rust-axum/scaffold/docs/engineering/rust-axum-rules.md +35 -0
- templates/rust-axum/scaffold/docs/playbooks/rust-axum-service.md +43 -0
- templates/rust-axum/scaffold/src/backend/Cargo.toml +20 -0
- templates/rust-axum/scaffold/src/backend/src/app.rs +12 -0
- templates/rust-axum/scaffold/src/backend/src/error.rs +48 -0
- templates/rust-axum/scaffold/src/backend/src/main.rs +24 -0
- templates/rust-axum/scaffold/src/backend/src/routes/health.rs +37 -0
- templates/rust-axum/scaffold/src/backend/src/routes/mod.rs +2 -0
- templates/rust-axum/scaffold-boundary.yaml +25 -0
- templates/rust-axum/skills/rust/SKILL.md +73 -0
- templates/rust-axum/skills/rust/references/anatomy.md +63 -0
- templates/rust-axum/stack.yaml +65 -0
- templates/rust-plain/scaffold/src/backend/Cargo.toml +6 -0
- templates/rust-plain/scaffold/src/backend/src/main.rs +3 -0
- templates/rust-plain/scaffold-boundary.yaml +23 -0
- templates/rust-plain/stack.yaml +51 -0
- templates/spring-boot/rules/backend.md +20 -0
- templates/spring-boot/scaffold/docs/engineering/spring-boot-rules.md +35 -0
- templates/spring-boot/scaffold/docs/playbooks/spring-boot-service.md +45 -0
- templates/spring-boot/scaffold/src/backend/mvnw +302 -0
- templates/spring-boot/scaffold/src/backend/pom.xml +69 -0
- templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/Application.java +16 -0
- templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/common/GlobalExceptionHandler.java +31 -0
- templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/health/HealthController.java +22 -0
- templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/health/HealthService.java +12 -0
- templates/spring-boot/scaffold/src/backend/src/main/java/com/example/app/health/HealthStatus.java +4 -0
- templates/spring-boot/scaffold/src/backend/src/test/java/com/example/app/health/HealthServiceTest.java +15 -0
- templates/spring-boot/scaffold-boundary.yaml +26 -0
- templates/spring-boot/skills/spring-boot/SKILL.md +84 -0
- templates/spring-boot/skills/spring-boot/references/anatomy.md +63 -0
- templates/spring-boot/stack.yaml +65 -0
- templates/svelte-sveltekit/rules/frontend.md +20 -0
- templates/svelte-sveltekit/scaffold/docs/engineering/svelte-sveltekit-rules.md +37 -0
- templates/svelte-sveltekit/scaffold/docs/playbooks/svelte-sveltekit-app.md +36 -0
- templates/svelte-sveltekit/scaffold/src/frontend/package.json +22 -0
- templates/svelte-sveltekit/scaffold/src/frontend/src/app.html +12 -0
- templates/svelte-sveltekit/scaffold/src/frontend/src/hooks.server.ts +14 -0
- templates/svelte-sveltekit/scaffold/src/frontend/src/lib/components/Greeting.svelte +6 -0
- templates/svelte-sveltekit/scaffold/src/frontend/src/lib/stores/count.test.ts +26 -0
- templates/svelte-sveltekit/scaffold/src/frontend/src/lib/stores/count.ts +4 -0
- templates/svelte-sveltekit/scaffold/src/frontend/src/routes/+layout.svelte +24 -0
- templates/svelte-sveltekit/scaffold/src/frontend/src/routes/+page.svelte +9 -0
- templates/svelte-sveltekit/scaffold/src/frontend/src/routes/+page.ts +7 -0
- templates/svelte-sveltekit/scaffold/src/frontend/src/routes/health/+server.ts +7 -0
- templates/svelte-sveltekit/scaffold/src/frontend/svelte.config.js +10 -0
- templates/svelte-sveltekit/scaffold/src/frontend/tsconfig.json +7 -0
- templates/svelte-sveltekit/scaffold/src/frontend/vite.config.ts +7 -0
- templates/svelte-sveltekit/scaffold/src/frontend/vitest.config.ts +11 -0
- templates/svelte-sveltekit/scaffold-boundary.yaml +29 -0
- templates/svelte-sveltekit/skills/svelte/SKILL.md +90 -0
- templates/svelte-sveltekit/skills/svelte/references/anatomy.md +62 -0
- templates/svelte-sveltekit/stack.yaml +70 -0
- templates/typescript-plain/scaffold/src/index.ts +3 -0
- templates/typescript-plain/scaffold/tsconfig.json +13 -0
- templates/typescript-plain/scaffold-boundary.yaml +22 -0
- templates/typescript-plain/stack.yaml +44 -0
- templates/vue-nuxt/rules/frontend.md +19 -0
- templates/vue-nuxt/scaffold/docs/engineering/nuxt-rules.md +30 -0
- templates/vue-nuxt/scaffold/docs/playbooks/nuxt-app.md +29 -0
- templates/vue-nuxt/scaffold/src/frontend/app.vue +3 -0
- templates/vue-nuxt/scaffold/src/frontend/nuxt.config.ts +11 -0
- templates/vue-nuxt/scaffold/src/frontend/package.json +20 -0
- templates/vue-nuxt/scaffold/src/frontend/pages/index.test.ts +19 -0
- templates/vue-nuxt/scaffold/src/frontend/pages/index.vue +11 -0
- templates/vue-nuxt/scaffold/src/frontend/vitest.config.ts +12 -0
- templates/vue-nuxt/scaffold-boundary.yaml +26 -0
- templates/vue-nuxt/skills/vue-nuxt/SKILL.md +57 -0
- templates/vue-nuxt/skills/vue-nuxt/references/anatomy.md +60 -0
- templates/vue-nuxt/stack.yaml +61 -0
- templates/wordpress/rules/backend.md +19 -0
- templates/wordpress/scaffold/docs/engineering/wordpress-rules.md +28 -0
- templates/wordpress/scaffold/docs/playbooks/wordpress-service.md +29 -0
- templates/wordpress/scaffold/src/backend/composer.json +17 -0
- templates/wordpress/scaffold/src/backend/phpcs.xml.dist +11 -0
- templates/wordpress/scaffold/src/backend/phpunit.xml +10 -0
- templates/wordpress/scaffold/src/backend/plugin/inc/health.php +8 -0
- templates/wordpress/scaffold/src/backend/plugin/plugin.php +28 -0
- templates/wordpress/scaffold/src/backend/tests/HealthStatusTest.php +15 -0
- templates/wordpress/scaffold/src/backend/theme/functions.php +18 -0
- templates/wordpress/scaffold/src/backend/theme/style.css +11 -0
- templates/wordpress/scaffold-boundary.yaml +23 -0
- templates/wordpress/skills/wordpress/SKILL.md +110 -0
- templates/wordpress/skills/wordpress/assets/wp-checklist.md +28 -0
- templates/wordpress/skills/wordpress/references/wp-development.md +75 -0
- templates/wordpress/skills/wordpress/references/wp-security.md +68 -0
- templates/wordpress/skills/wordpress/scripts/scan_wp_smells.py +91 -0
- templates/wordpress/skills/wordpress/versions.json +16 -0
- templates/wordpress/stack.yaml +60 -0
- thinking_os/__init__.py +1 -0
- thinking_os/_agent_markers.py +32 -0
- thinking_os/background.py +405 -0
- thinking_os/bootstrap_outcomes.py +200 -0
- thinking_os/budget.py +302 -0
- thinking_os/capture.py +495 -0
- thinking_os/cognition.py +516 -0
- thinking_os/cognition_schemas.py +517 -0
- thinking_os/compress.py +192 -0
- thinking_os/concepts.py +233 -0
- thinking_os/dashboard.py +159 -0
- thinking_os/database.py +2883 -0
- thinking_os/decay.py +393 -0
- thinking_os/digest.py +295 -0
- thinking_os/dispatcher.py +192 -0
- thinking_os/dispatcher_helpers.py +48 -0
- thinking_os/dispatchers/__init__.py +3 -0
- thinking_os/dispatchers/default.py +47 -0
- thinking_os/distill.py +192 -0
- thinking_os/doc_indexer.py +905 -0
- thinking_os/embeddings.py +943 -0
- thinking_os/formula_composer.py +556 -0
- thinking_os/gate_marker.py +75 -0
- thinking_os/graph.py +296 -0
- thinking_os/graph_indexer.py +360 -0
- thinking_os/health_check.py +517 -0
- thinking_os/impact.py +119 -0
- thinking_os/memory_gc.py +356 -0
- thinking_os/migrator_embeddings.py +318 -0
- thinking_os/precision.py +194 -0
- thinking_os/record_outcome.py +399 -0
- thinking_os/repair.py +105 -0
- thinking_os/retrieval_quality.py +239 -0
- thinking_os/roles_state.py +168 -0
- thinking_os/sanitizer.py +320 -0
- thinking_os/server.py +3160 -0
- thinking_os/session_enrich.py +272 -0
- thinking_os/session_observe_worker.py +111 -0
- thinking_os/session_startup.py +74 -0
- thinking_os/session_summary.py +245 -0
- thinking_os/task_analyzer.py +462 -0
- thinking_os/task_parser.py +342 -0
- thinking_os/task_sync.py +73 -0
- thinking_os/tools/__init__.py +6 -0
- thinking_os/tools/_shared.py +947 -0
- thinking_os/tools/cognition.py +1867 -0
- thinking_os/tools/docs.py +770 -0
- thinking_os/tools/learning.py +2078 -0
- thinking_os/tools/logs.py +79 -0
- thinking_os/tools/memory.py +840 -0
- thinking_os/tools/metrics.py +200 -0
- thinking_os/tools/retrieve.py +415 -0
- thinking_os/tools/routing.py +658 -0
- thinking_os/tools/tasks.py +449 -0
- thinking_os/tools/trajectory.py +181 -0
- thinking_os/tracing.py +235 -0
- web/__init__.py +5 -0
- web/_cache.py +118 -0
- web/_deps.py +56 -0
- web/_envelope.py +85 -0
- web/_project_context.py +140 -0
- web/chat_providers.py +108 -0
- web/init_jobs.py +216 -0
- web/routes/__init__.py +25 -0
- web/routes/_bounded_read.py +76 -0
- web/routes/board.py +1089 -0
- web/routes/cognition.py +1838 -0
- web/routes/config.py +635 -0
- web/routes/graph.py +513 -0
- web/routes/health.py +180 -0
- web/routes/hooks.py +288 -0
- web/routes/hub.py +1219 -0
- web/routes/logs.py +374 -0
- web/routes/metrics.py +43 -0
- web/routes/observability.py +400 -0
- web/routes/patterns.py +227 -0
- web/routes/presence.py +609 -0
- web/routes/roles.py +446 -0
- web/routes/scheduled.py +261 -0
- web/routes/search.py +238 -0
- web/routes/sessions.py +220 -0
- web/routes/settings.py +363 -0
- web/routes/stream.py +547 -0
- web/security.py +157 -0
- 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))
|