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