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