@bongos/core 1.17.0
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.
- package/.bongos-core.json +5727 -0
- package/.claude/skills/blocker-review/SKILL.md +91 -0
- package/.claude/skills/blocker-solve/SKILL.md +96 -0
- package/.claude/skills/builder-backup/SKILL.md +54 -0
- package/.claude/skills/builder-box/SKILL.md +45 -0
- package/.claude/skills/builder-claim/SKILL.md +76 -0
- package/.claude/skills/builder-connect/SKILL.md +59 -0
- package/.claude/skills/builder-cost/SKILL.md +47 -0
- package/.claude/skills/builder-end/SKILL.md +81 -0
- package/.claude/skills/builder-exit/SKILL.md +88 -0
- package/.claude/skills/builder-key/SKILL.md +40 -0
- package/.claude/skills/builder-reauth/SKILL.md +50 -0
- package/.claude/skills/builder-redteam/SKILL.md +109 -0
- package/.claude/skills/builder-release/SKILL.md +41 -0
- package/.claude/skills/builder-setup/SKILL.md +62 -0
- package/.claude/skills/builder-ship/SKILL.md +79 -0
- package/.claude/skills/builder-stage/SKILL.md +51 -0
- package/.claude/skills/builder-start/SKILL.md +46 -0
- package/.claude/skills/close-box/SKILL.md +43 -0
- package/.claude/skills/design-sync/SKILL.md +69 -0
- package/.claude/skills/dev/SKILL.md +49 -0
- package/.claude/skills/feedback/SKILL.md +32 -0
- package/.claude/skills/figma-design-sync/SKILL.md +74 -0
- package/.claude/skills/goal-review/SKILL.md +89 -0
- package/.claude/skills/idea-triage/SKILL.md +144 -0
- package/.claude/skills/ideate/SKILL.md +82 -0
- package/.claude/skills/merge-mode/SKILL.md +169 -0
- package/.claude/skills/new-project/SKILL.md +108 -0
- package/.claude/skills/otb-character-review/SKILL.md +71 -0
- package/.claude/skills/otb-design-review/SKILL.md +47 -0
- package/.claude/skills/otb-feedback-capture/SKILL.md +45 -0
- package/.claude/skills/otb-figma-sync/SKILL.md +54 -0
- package/.claude/skills/otb-tile-generate/SKILL.md +45 -0
- package/.claude/skills/paint/SKILL.md +70 -0
- package/.claude/skills/planning-session/SKILL.md +299 -0
- package/.claude/skills/priority-session/SKILL.md +134 -0
- package/.claude/skills/read-session-export/SKILL.md +58 -0
- package/.claude/skills/recall/SKILL.md +52 -0
- package/.claude/skills/session-handoff/SKILL.md +46 -0
- package/.claude/skills/status/SKILL.md +33 -0
- package/.devcontainer/Dockerfile +69 -0
- package/.devcontainer/README.md +73 -0
- package/.devcontainer/devcontainer-lock.json +19 -0
- package/.devcontainer/devcontainer.json +93 -0
- package/.devcontainer/healthcheck.sh +84 -0
- package/.devcontainer/init-firewall.sh +198 -0
- package/.devcontainer/post-create.sh +153 -0
- package/.dockerignore +36 -0
- package/.env.local.example +32 -0
- package/.gitattributes +83 -0
- package/.gitignore +91 -0
- package/CLAUDE.md +242 -0
- package/CODE_OF_CONDUCT.md +46 -0
- package/CONTRIBUTING.md +51 -0
- package/Dockerfile +46 -0
- package/GOVERNANCE.md +44 -0
- package/LICENSE +661 -0
- package/README.md +88 -0
- package/bin/bongos.js +222 -0
- package/clients/bongos-client/README.md +48 -0
- package/clients/bongos-client/bongos-client.global.js +617 -0
- package/clients/bongos-client/examples/hello-world.mjs +62 -0
- package/clients/bongos-client/index.cjs +619 -0
- package/clients/bongos-client/index.d.ts +793 -0
- package/clients/bongos-client/index.mjs +614 -0
- package/clients/bongos-client/package.json +32 -0
- package/config/branding.neutral.json +60 -0
- package/config/design-tokens.neutral.json +51 -0
- package/config/design-tokens.schema.json +96 -0
- package/config/modules.neutral.json +9 -0
- package/docker-compose.yml +52 -0
- package/docker-entrypoint.sh +30 -0
- package/docs/adr/0001-stack-choice-single-vps.md +45 -0
- package/docs/adr/0002-digitalocean-over-hetzner.md +27 -0
- package/docs/adr/0003-cloudflare-origin-cert-over-letsencrypt.md +35 -0
- package/docs/adr/0004-amazonprimea-name-and-trademark-acceptance.md +45 -0
- package/docs/adr/0005-invisible-other-players-in-v1.md +37 -0
- package/docs/adr/0006-singleton-worldroom-plus-queueroom.md +30 -0
- package/docs/adr/0007-persistent-identity-via-localstorage-uuid.md +31 -0
- package/docs/adr/0008-family-based-tile-generation.md +86 -0
- package/docs/adr/0008-google-chat-oauth-user-auth.md +37 -0
- package/docs/adr/0009-autotile-architecture.md +181 -0
- package/docs/adr/0010-pms-architecture.md +70 -0
- package/docs/adr/0011-terrain-id-rendering-model.md +174 -0
- package/docs/adr/0012-ground-object-cell-split.md +208 -0
- package/docs/adr/0013-paths-as-object-overlays-with-autotile-dispatch.md +158 -0
- package/docs/adr/0014-families-canonical-over-vocabulary.md +103 -0
- package/docs/adr/0015-task-dependencies-and-auto-promotion.md +125 -0
- package/docs/adr/0016-trust-boundary-server-enforced-permissions.md +132 -0
- package/docs/adr/0017-code-simplification-and-repo-cleanliness.md +74 -0
- package/docs/adr/0018-three-rank-model-goes-live.md +97 -0
- package/docs/adr/0019-red-team-bounty-payout-table.md +52 -0
- package/docs/adr/0020-security-model-public-stub.md +70 -0
- package/docs/adr/0021-per-builder-skill-model-preferences.md +99 -0
- package/docs/adr/0022-secrets-policy.md +124 -0
- package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +118 -0
- package/docs/adr/0024-cloneable-repo-local-first-memory.md +277 -0
- package/docs/adr/0024-multi-agent-system-architecture.md +125 -0
- package/docs/adr/0025-offsite-backup-vendor-digitalocean-spaces.md +59 -0
- package/docs/adr/0025-structured-criterion-task-link.md +44 -0
- package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +117 -0
- package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +170 -0
- package/docs/adr/0028-honest-llm-spend-accounting.md +109 -0
- package/docs/adr/0029-status-page-outage-resilience.md +59 -0
- package/docs/adr/0030-builders-hall-live-updates.md +59 -0
- package/docs/adr/0031-cloud-dev-environments-for-builders.md +222 -0
- package/docs/adr/0032-discord-mirror-of-gds.md +50 -0
- package/docs/adr/0033-discord-bot-service-principal.md +82 -0
- package/docs/adr/0034-thetes-graduated-newcomer-rank.md +53 -0
- package/docs/adr/0035-builder-onboarding-three-paths.md +128 -0
- package/docs/adr/0036-builders-subdomain-and-ui-reorg.md +40 -0
- package/docs/adr/0037-discord-channel-management.md +51 -0
- package/docs/adr/0038-chromebook-ttyd-cloudflare-tunnel.md +111 -0
- package/docs/adr/0039-setup-first-onboarding-ux.md +49 -0
- package/docs/adr/0040-remote-control-default-browser-onramp.md +47 -0
- package/docs/adr/0041-temporary-grader-bypass-killswitch.md +40 -0
- package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +82 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +74 -0
- package/docs/adr/0044-mediterranean-palette-replacement.md +73 -0
- package/docs/adr/0044-per-box-live-game-preview.md +57 -0
- package/docs/adr/0045-devbox-desktop-app.md +133 -0
- package/docs/adr/0046-sandbox-first-review-gate.md +72 -0
- package/docs/adr/0047-discord-bugs-intake-anti-abuse.md +36 -0
- package/docs/adr/0048-works-in-progress-ia-split.md +32 -0
- package/docs/adr/0049-split-parallel-safety-contract.md +46 -0
- package/docs/adr/0050-device-flow-admission-invite-gated-by-default.md +43 -0
- package/docs/adr/0051-full-session-transcript-corpus.md +57 -0
- package/docs/adr/0052-sandbox-for-everyone-game-only-preview.md +70 -0
- package/docs/adr/0053-scoped-dev-box-session.md +55 -0
- package/docs/adr/0054-session-token-reward-cost-plus.md +63 -0
- package/docs/adr/0055-server-mediated-branch-publish.md +47 -0
- package/docs/adr/0056-prod-deploy-script-mirror-and-healthcheck-retry.md +62 -0
- package/docs/adr/0057-container-cost-ledger.md +28 -0
- package/docs/adr/0057-discord-archon-approval-channels.md +64 -0
- package/docs/adr/0058-automatic-gate-surface-gatekeeper.md +64 -0
- package/docs/adr/0059-single-approval-remove-devbox-approval-gate.md +37 -0
- package/docs/adr/0060-gds-retrieval-layer.md +97 -0
- package/docs/adr/0061-context-layer-decomposition.md +61 -0
- package/docs/adr/0062-medusa-instance-model-decoupling.md +107 -0
- package/docs/adr/0063-generated-repo-map-symbol-skeleton.md +44 -0
- package/docs/adr/0064-rename-medusa-to-cloud-bongos.md +49 -0
- package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +69 -0
- package/docs/adr/0066-generated-file-map-sections.md +51 -0
- package/docs/adr/0067-session-start-fast-forward-pull.md +43 -0
- package/docs/adr/0068-shared-art-key-for-newcomers.md +38 -0
- package/docs/adr/0069-hall-live-channel-sse-decouple.md +35 -0
- package/docs/adr/0070-deterministic-session-card-delivery.md +44 -0
- package/docs/adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md +59 -0
- package/docs/adr/0072-bongos-app-mac-signed-first-windows-deferred.md +35 -0
- package/docs/adr/0072-dev-box-code-staleness-visibility.md +39 -0
- package/docs/adr/0073-builder-needs-signal-and-byok-gemini-key.md +64 -0
- package/docs/adr/0073-secrets-scan-exclude-uri-detector.md +37 -0
- package/docs/adr/0074-memory-map-stays-light.md +32 -0
- package/docs/adr/0075-per-system-cost-attribution.md +37 -0
- package/docs/adr/0076-in-session-deterministic-compaction.md +58 -0
- package/docs/adr/0077-content-addressed-llm-cache.md +32 -0
- package/docs/adr/0078-deterministic-first-cron.md +48 -0
- package/docs/adr/0079-value-gated-autonomous-engagement.md +39 -0
- package/docs/adr/0080-llm-cache-api-transport-and-trust-model.md +46 -0
- package/docs/adr/0081-tool-agnostic-design-layer.md +62 -0
- package/docs/adr/0082-productized-key-storage-provisioning.md +32 -0
- package/docs/adr/0082-server-side-merge-conflict-auto-resolution.md +55 -0
- package/docs/adr/0083-modular-architecture-strangler-build-plan.md +143 -0
- package/docs/adr/0084-auto-derived-per-task-rank-floor.md +86 -0
- package/docs/adr/0085-remove-default-per-builder-spend-cap.md +39 -0
- package/docs/adr/0086-goal-scoped-work-hierarchy.md +123 -0
- package/docs/adr/0087-bongos-app-architecture-and-handoff-contract.md +49 -0
- package/docs/adr/0087-compete-on-governance-not-tooling-cursor.md +65 -0
- package/docs/adr/0088-consistent-character-generative-animation.md +169 -0
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +179 -0
- package/docs/adr/0090-metic-task-authoring.md +73 -0
- package/docs/adr/0091-bounding-the-kernel-and-db-carve.md +106 -0
- package/docs/adr/0092-hosting-module-and-on-prem-target.md +117 -0
- package/docs/adr/0093-tranche-2-core-carve-sequence.md +72 -0
- package/docs/adr/0094-repo-atlas-self-drawing-codebase-map.md +49 -0
- package/docs/adr/0095-borrowed-memory-and-retrieval-concepts.md +65 -0
- package/docs/adr/0095-cross-agent-context-management.md +58 -0
- package/docs/adr/0096-require-reward-before-workable.md +209 -0
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +46 -0
- package/docs/adr/0097-retroactive-reward-backfill.md +124 -0
- package/docs/adr/0098-public-mirror-publish-manifest.md +92 -0
- package/docs/adr/0099-delayed-redacted-mirror-export.md +55 -0
- package/docs/adr/0100-two-repo-instance-model.md +60 -0
- package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +90 -0
- package/docs/adr/0102-finish-bounding-the-kernel.md +36 -0
- package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +70 -0
- package/docs/adr/0103-gdsv4-bongos-consolidation.md +28 -0
- package/docs/adr/0104-trust-gds-api-channel-in-auto-mode.md +42 -0
- package/docs/adr/0105-instance-seed-migrations-out-of-core.md +47 -0
- package/docs/adr/0106-goal-owner-manager-authority.md +84 -0
- package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +83 -0
- package/docs/adr/0108-instance-composition-configurable-root.md +87 -0
- package/docs/adr/0109-self-describing-openapi-and-hosted-docs.md +44 -0
- package/docs/adr/0110-untrack-whole-file-generated-nav-docs.md +49 -0
- package/docs/adr/0111-instance-hosting-provisioning-module.md +135 -0
- package/docs/adr/0112-private-goals-invitations-join-requests.md +72 -0
- package/docs/adr/0113-sandbox-studio-descope-superseded-by-0044-0052.md +41 -0
- package/docs/adr/0114-instance-docs-discovery-contract.md +32 -0
- package/docs/adr/0115-scheduled-routines-portability.md +31 -0
- package/docs/adr/0116-api-error-envelope-and-code-taxonomy.md +62 -0
- package/docs/adr/0117-api-versioning-and-deprecation-policy.md +50 -0
- package/docs/adr/0118-api-contract-source-of-truth-code-first.md +47 -0
- package/docs/adr/0119-api-pagination-contract.md +34 -0
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +108 -0
- package/docs/adr/0121-greenfield-vs-brownfield-onboarding-adopt-existing-repo.md +97 -0
- package/docs/adr/0122-onboarding-diagram-renders-deploy-rendered-off-git.md +43 -0
- package/docs/adr/0123-box-idle-sweep-autosave-before-destroy.md +36 -0
- package/docs/adr/0124-typed-api-response-bodies.md +37 -0
- package/docs/adr/0125-control-plane-scaffolds-standalone-instance-repo.md +36 -0
- package/docs/adr/0126-dedicated-cloudbongos-control-plane-droplet.md +30 -0
- package/docs/adr/0127-cross-box-fleet-observability.md +64 -0
- package/docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md +46 -0
- package/docs/adr/0129-cloudbongos-control-plane-pull-deploy.md +34 -0
- package/docs/adr/0130-provision-remote-exec-over-ssh.md +61 -0
- package/docs/adr/0131-rank-scoped-skill-visibility.md +47 -0
- package/docs/adr/0132-cohost-fleet-control-plane-migration.md +54 -0
- package/docs/adr/0133-github-app-manifest-onboarding-signin.md +42 -0
- package/docs/adr/0134-private-first-npm-distribution.md +38 -0
- package/docs/adr/README.md +208 -0
- package/docs/api/openapi.json +13720 -0
- package/docs/api-reference.md +437 -0
- package/docs/architecture.md +244 -0
- package/docs/branding-contract.md +66 -0
- package/docs/branding-fill-prompt.md +25 -0
- package/docs/canonical-permissions.md +241 -0
- package/docs/design/container-exterior-tiles.md +49 -0
- package/docs/design/container-exterior.md +81 -0
- package/docs/design/container-interior.md +77 -0
- package/docs/design/modular-architecture/00-research-report.md +107 -0
- package/docs/design/modular-architecture/01-architecture.md +125 -0
- package/docs/design/modular-architecture/02-module-map.md +116 -0
- package/docs/design/modular-architecture/03-builder-flows.md +104 -0
- package/docs/design/modular-architecture/04-module-lifecycle.md +62 -0
- package/docs/design/modular-architecture/README.md +27 -0
- package/docs/design/vanilla-hall-ui-redesign-scope.md +284 -0
- package/docs/design/world-map-scroll.md +41 -0
- package/docs/design-contract.md +320 -0
- package/docs/file-map.md +418 -0
- package/docs/handoff-template.md +92 -0
- package/docs/modules-contract.md +293 -0
- package/docs/onboarding/browser-terminal-guide.md +73 -0
- package/docs/onboarding/diagrams/01-task-lifecycle.mmd +28 -0
- package/docs/onboarding/diagrams/02-rank-ladder.mmd +54 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +37 -0
- package/docs/onboarding/diagrams/04-architecture.mmd +51 -0
- package/docs/onboarding/diagrams/05-versioning.mmd +29 -0
- package/docs/onboarding/diagrams/README.md +94 -0
- package/docs/onboarding/diagrams/assertions.json +170 -0
- package/docs/onboarding/diagrams/puppeteer-config.json +1 -0
- package/docs/onboarding/diagrams/render-config.json +9 -0
- package/docs/onboarding/drachmae-and-karma.md +178 -0
- package/docs/onboarding/primer.md +202 -0
- package/docs/project-context.template.md +67 -0
- package/docs/recipes/README.md +35 -0
- package/docs/recipes/audit-replay.md +100 -0
- package/docs/recipes/bongos-cli-release.md +66 -0
- package/docs/recipes/builder-box-lifecycle.md +152 -0
- package/docs/recipes/claude-design-loop.md +143 -0
- package/docs/recipes/claude-gemini-bongos-doors.md +155 -0
- package/docs/recipes/cloudbongos-staging-gate.md +139 -0
- package/docs/recipes/cloudbongos-standup.md +111 -0
- package/docs/recipes/devbox-app-release.md +93 -0
- package/docs/recipes/devbox-app-windows-test.md +128 -0
- package/docs/recipes/failed-ship-recovery-proof.md +102 -0
- package/docs/recipes/figma-design-loop.md +129 -0
- package/docs/recipes/gds-db-backup.md +255 -0
- package/docs/recipes/instance-deploy.md +37 -0
- package/docs/recipes/managed-settings-remote-control.md +416 -0
- package/docs/recipes/memory-backup.md +71 -0
- package/docs/recipes/memory-sync-conflicts.md +98 -0
- package/docs/recipes/multi-builder-merge.md +78 -0
- package/docs/recipes/ops-gotchas.md +212 -0
- package/docs/recipes/overnight-builder.md +53 -0
- package/docs/recipes/packaging-the-core.md +49 -0
- package/docs/recipes/parallel-load-results-2026-05-30.md +63 -0
- package/docs/recipes/parallel-load-test.md +32 -0
- package/docs/recipes/pixel-art-pipeline.md +411 -0
- package/docs/recipes/private-npm-distribution.md +79 -0
- package/docs/recipes/search-index-ingestion.md +67 -0
- package/docs/recipes/self-host.md +60 -0
- package/docs/recipes/ship-and-regrade.md +72 -0
- package/docs/recipes/staging-data.md +50 -0
- package/docs/recipes/staging-environment.md +215 -0
- package/docs/recipes/upgrading-the-core.md +58 -0
- package/docs/research/cursor-competitive-analysis.md +255 -0
- package/docs/research/llm-model-agnostic-and-cheaper-grader.md +227 -0
- package/migrations/003_pms.sql +284 -0
- package/migrations/004_pms_cost_idempotency.sql +13 -0
- package/migrations/005_pms_smoke_filter.sql +44 -0
- package/migrations/006_pms_v2.sql +378 -0
- package/migrations/007_pms_v2_hierarchy_trigger.sql +59 -0
- package/migrations/008_pms_v2_migration_allocator.sql +27 -0
- package/migrations/009_pms_v2_blockers_table.sql +68 -0
- package/migrations/010_uptime.sql +29 -0
- package/migrations/011_backfill_v2_v3_done_when.sql +20 -0
- package/migrations/012_pms_v2_task_lifecycle_states.sql +82 -0
- package/migrations/013_claim_locking.sql +22 -0
- package/migrations/014_pms_task_discipline.sql +86 -0
- package/migrations/015_pms_estimation_calibration.sql +140 -0
- package/migrations/016_pms_task_grades.sql +60 -0
- package/migrations/017_version_progress_exclude_abandoned.sql +49 -0
- package/migrations/019_rename_pms_v3_to_gds_v3.sql +65 -0
- package/migrations/020_task_dependencies.sql +99 -0
- package/migrations/021_v3_renumber_and_deps.sql +224 -0
- package/migrations/022_v3_reconcile_dep_gated_status.sql +41 -0
- package/migrations/023_pms_rank.sql +30 -0
- package/migrations/025_builder_exit_status.sql +15 -0
- package/migrations/027_builders_preferred_disciplines.sql +46 -0
- package/migrations/028_tasks_parallel_safe.sql +71 -0
- package/migrations/029_rank_three_live.sql +32 -0
- package/migrations/030_vulnerability_reports.sql +61 -0
- package/migrations/031_reward_simplification.sql +57 -0
- package/migrations/032_repo_health_snapshots.sql +56 -0
- package/migrations/033_audit_log.sql +64 -0
- package/migrations/034_lifecycle_breakdown_leaf_only.sql +62 -0
- package/migrations/035_auth_token_hygiene.sql +66 -0
- package/migrations/037_builder_skill_model_prefs.sql +44 -0
- package/migrations/038_task_override_requests.sql +51 -0
- package/migrations/039_newcomer_friendly_column.sql +124 -0
- package/migrations/040_builder_karma.sql +58 -0
- package/migrations/041_kind_multipliers_and_idea_bonus.sql +42 -0
- package/migrations/042_criterion_proposal_workflow.sql +78 -0
- package/migrations/043_builder_onboarding_observability.sql +66 -0
- package/migrations/044_tasks_requires_rank.sql +97 -0
- package/migrations/045_builder_onboarding_state.sql +147 -0
- package/migrations/046_cost_log_builder_attribution.sql +42 -0
- package/migrations/054_builder_memory.sql +108 -0
- package/migrations/055_task_criteria.sql +90 -0
- package/migrations/058_peer_votes.sql +41 -0
- package/migrations/059_onboarding_pick_disciplines.sql +59 -0
- package/migrations/060_access_requests.sql +46 -0
- package/migrations/061_backfill_override_ship_attribution.sql +29 -0
- package/migrations/062_session_records.sql +34 -0
- package/migrations/063_session_records_tool_errors_idx.sql +11 -0
- package/migrations/064_grade_attempts.sql +18 -0
- package/migrations/065_bfg_principal.sql +58 -0
- package/migrations/066_live_notify_triggers.sql +69 -0
- package/migrations/067_builder_boxes.sql +117 -0
- package/migrations/067_discord_bot_foundation.sql +69 -0
- package/migrations/068_thetes_requires_rank.sql +27 -0
- package/migrations/069_builder_ssh_keys.sql +66 -0
- package/migrations/070_box_intents.sql +101 -0
- package/migrations/071_box_first_provision_approval.sql +46 -0
- package/migrations/072_onboarding_ship_three_remove_primer.sql +80 -0
- package/migrations/073_box_terminal_access.sql +46 -0
- package/migrations/075_builder_sound_prefs.sql +28 -0
- package/migrations/078_box_claude_active.sql +22 -0
- package/migrations/079_box_first_connected_at.sql +20 -0
- package/migrations/081_tasks_needs_migration.sql +34 -0
- package/migrations/084_tasks_security_sensitive.sql +24 -0
- package/migrations/085_security_engagement_docs.sql +34 -0
- package/migrations/086_rename_disciplines.sql +125 -0
- package/migrations/088_builders_anthropic_link.sql +28 -0
- package/migrations/089_builders_monthly_budget.sql +19 -0
- package/migrations/090_box_scoped_sessions.sql +38 -0
- package/migrations/091_session_token_reward.sql +44 -0
- package/migrations/093_tasks_sandbox_review.sql +22 -0
- package/migrations/102_remove_box_first_provision_approval.sql +58 -0
- package/migrations/103_search_chunks.sql +90 -0
- package/migrations/104_builder_box_blocked.sql +34 -0
- package/migrations/105_box_host_keys.sql +35 -0
- package/migrations/111_medusa_customer0_framing.sql +35 -0
- package/migrations/112_drop_track_check.sql +25 -0
- package/migrations/113_merge_lease.sql +16 -0
- package/migrations/114_cloudbongos_rename_live_strings.sql +32 -0
- package/migrations/118_currency_label_decoupling.sql +23 -0
- package/migrations/119_builder_behavior_prefs.sql +31 -0
- package/migrations/121_box_code_version.sql +37 -0
- package/migrations/122_builder_gemini_key.sql +30 -0
- package/migrations/123_cost_log_tiers.sql +33 -0
- package/migrations/124_cost_baseline.sql +45 -0
- package/migrations/125_llm_cache.sql +54 -0
- package/migrations/126_autonomy_runs.sql +33 -0
- package/migrations/143_cost_calibration.sql +63 -0
- package/migrations/145_backfill_task_requires_rank.sql +84 -0
- package/migrations/160_goal_tier.sql +88 -0
- package/migrations/163_polymorphic_dependencies.sql +187 -0
- package/migrations/164_event_sound_prefs.sql +20 -0
- package/migrations/165_task_module_key.sql +15 -0
- package/migrations/171_claims_published_head_sha.sql +24 -0
- package/migrations/174_close_orphaned_claims_on_terminal_tasks.sql +30 -0
- package/migrations/175_task_grades_diff_hash.sql +9 -0
- package/migrations/176_promotion_respects_open_blockers.sql +58 -0
- package/migrations/176_ui_discipline.sql +40 -0
- package/migrations/177_require_reward_before_workable.sql +76 -0
- package/migrations/178_reward_gate_auto_assign_revert_autopromote.sql +61 -0
- package/migrations/179_search_chunk_usefulness.sql +33 -0
- package/migrations/180_one_active_claim_per_session.sql +54 -0
- package/migrations/181_cost_log_skill_name.sql +33 -0
- package/migrations/184_claim_release_requested.sql +25 -0
- package/migrations/185_module_upstreaming_goal_wiring.sql +59 -0
- package/migrations/187_goal_sort_order.sql +30 -0
- package/migrations/187_tasks_kind_check_add_verify.sql +21 -0
- package/migrations/README.md +88 -0
- package/migrations/core_188_goal_visibility_and_membership_requests.sql +71 -0
- package/migrations/core_189_core_upgrades_ledger.sql +37 -0
- package/migrations/core_189_credit_log_task_id_index.sql +19 -0
- package/migrations/core_190_rebuild_claimable_task_views.sql +71 -0
- package/migrations/instance/018_pms_v3_planning_seed.sql +131 -0
- package/migrations/instance/080_gds_v4_planning_seed.sql +75 -0
- package/migrations/instance/110_medusa_planning_seed.sql +70 -0
- package/migrations/instance/120_bongos_v1_planning_seed.sql +69 -0
- package/migrations/instance/144_bongos_v1_module_system_criterion.sql +26 -0
- package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +29 -0
- package/migrations/instance/172_v2_world_planning_seed.sql +163 -0
- package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +33 -0
- package/migrations/instance/182_gdsv4_bongos_consolidation.sql +132 -0
- package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +116 -0
- package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +69 -0
- package/modules/autonomy/CLAUDE.md +49 -0
- package/modules/autonomy/db.js +50 -0
- package/modules/autonomy/module.json +13 -0
- package/modules/autonomy/pollers/routine-timers.js +146 -0
- package/modules/autonomy/routes/autonomy.js +37 -0
- package/modules/builder-settings/CLAUDE.md +58 -0
- package/modules/builder-settings/builder-needs.js +115 -0
- package/modules/builder-settings/db.js +168 -0
- package/modules/builder-settings/display-name.js +84 -0
- package/modules/builder-settings/event-sound-prefs.js +169 -0
- package/modules/builder-settings/interaction-prefs.js +201 -0
- package/modules/builder-settings/model-alloc-prefs.js +147 -0
- package/modules/builder-settings/module.json +10 -0
- package/modules/builder-settings/render-prefs.js +103 -0
- package/modules/builder-settings/routes/settings.js +417 -0
- package/modules/builder-settings/settings.js +52 -0
- package/modules/builder-settings/skill-prefs.js +196 -0
- package/modules/builder-settings/sound-prefs.js +178 -0
- package/modules/builder-settings/wandering-prefs.js +282 -0
- package/modules/dev-box/CLAUDE.md +38 -0
- package/modules/dev-box/box-access.js +275 -0
- package/modules/dev-box/box-credential.js +94 -0
- package/modules/dev-box/box-onboard.js +486 -0
- package/modules/dev-box/boxes.js +842 -0
- package/modules/dev-box/db.js +39 -0
- package/modules/dev-box/module.json +14 -0
- package/modules/dev-box/routes/box.js +977 -0
- package/modules/discord/CLAUDE.md +50 -0
- package/modules/discord/auth.js +54 -0
- package/modules/discord/bug-attachments.js +210 -0
- package/modules/discord/db.js +96 -0
- package/modules/discord/discord-approvals.js +145 -0
- package/modules/discord/discord-audit.js +29 -0
- package/modules/discord/discord-bot.js +545 -0
- package/modules/discord/discord-bugs.js +257 -0
- package/modules/discord/discord-channels.js +412 -0
- package/modules/discord/discord-inbound.js +147 -0
- package/modules/discord/discord-message-buffer.js +54 -0
- package/modules/discord/discord-reaction.js +186 -0
- package/modules/discord/discord-role-sync.js +318 -0
- package/modules/discord/module.json +14 -0
- package/modules/discord/pollers/discord-bot.js +19 -0
- package/modules/discord/routes/bug-attachments.js +44 -0
- package/modules/discord/routes/discord.js +102 -0
- package/modules/discord/ship-broadcast.js +78 -0
- package/modules/discord/webhook.js +74 -0
- package/modules/economy/CLAUDE.md +48 -0
- package/modules/economy/achievements.js +594 -0
- package/modules/economy/cost-classify.js +331 -0
- package/modules/economy/cost-summary-shape.js +90 -0
- package/modules/economy/cost.js +438 -0
- package/modules/economy/credits.js +430 -0
- package/modules/economy/migrations/economy_001_fix_gds_shipper_description.sql +91 -0
- package/modules/economy/module.json +10 -0
- package/modules/economy/reward.js +61 -0
- package/modules/economy/routes/cost.js +97 -0
- package/modules/economy/routes/leaderboard.js +26 -0
- package/modules/economy/routes/reward.js +42 -0
- package/modules/economy/streak.js +285 -0
- package/modules/grading/CLAUDE.md +42 -0
- package/modules/grading/db.js +39 -0
- package/modules/grading/grader-rubric.json +151 -0
- package/modules/grading/grader-workers/efficiency.js +213 -0
- package/modules/grading/grader-workers/extract-json.js +80 -0
- package/modules/grading/grader-workers/fence.js +100 -0
- package/modules/grading/grader-workers/hacker.js +239 -0
- package/modules/grading/grader-workers/index.js +183 -0
- package/modules/grading/grader-workers/narc.js +207 -0
- package/modules/grading/grader-workers/quality.js +198 -0
- package/modules/grading/grader-workers/rubric.js +20 -0
- package/modules/grading/grader-workers/worker-prompt.js +89 -0
- package/modules/grading/grader-workers/worker-verdict.js +50 -0
- package/modules/grading/grader.js +1790 -0
- package/modules/grading/module.json +12 -0
- package/modules/grading/routes/grade.js +51 -0
- package/modules/hall-ui/CLAUDE.md +61 -0
- package/modules/hall-ui/module.json +14 -0
- package/modules/hall-ui/public/atlas.html +180 -0
- package/modules/hall-ui/public/atlas.js +336 -0
- package/modules/hall-ui/public/box-panel.js +423 -0
- package/modules/hall-ui/public/brand-apply.js +117 -0
- package/modules/hall-ui/public/builders.js +3028 -0
- package/modules/hall-ui/public/chime.wav +0 -0
- package/modules/hall-ui/public/copy-on-click.js +174 -0
- package/modules/hall-ui/public/cursors/arrow.png +0 -0
- package/modules/hall-ui/public/cursors/hand-pointer.png +0 -0
- package/modules/hall-ui/public/cursors/magnifier-out.png +0 -0
- package/modules/hall-ui/public/cursors/magnifier.png +0 -0
- package/modules/hall-ui/public/diagram-viewer.css +125 -0
- package/modules/hall-ui/public/diagram-viewer.js +330 -0
- package/modules/hall-ui/public/diagrams.html +113 -0
- package/modules/hall-ui/public/dom-utils.js +212 -0
- package/modules/hall-ui/public/drachmae.html +68 -0
- package/modules/hall-ui/public/drachmae.js +136 -0
- package/modules/hall-ui/public/gate.html +143 -0
- package/modules/hall-ui/public/gate.js +228 -0
- package/modules/hall-ui/public/goals-page.js +1297 -0
- package/modules/hall-ui/public/goals.html +376 -0
- package/modules/hall-ui/public/goals.js +119 -0
- package/modules/hall-ui/public/hall-widgets.js +189 -0
- package/modules/hall-ui/public/harbor.html +167 -0
- package/modules/hall-ui/public/harbor.js +335 -0
- package/modules/hall-ui/public/index.html +116 -0
- package/modules/hall-ui/public/instances-panel.js +172 -0
- package/modules/hall-ui/public/md-reader.js +234 -0
- package/modules/hall-ui/public/modules.html +119 -0
- package/modules/hall-ui/public/modules.js +154 -0
- package/modules/hall-ui/public/new-project-wizard.js +186 -0
- package/modules/hall-ui/public/not-ready.html +48 -0
- package/modules/hall-ui/public/pair.html +119 -0
- package/modules/hall-ui/public/pair.js +132 -0
- package/modules/hall-ui/public/primer.css +404 -0
- package/modules/hall-ui/public/primer.html +85 -0
- package/modules/hall-ui/public/primer.js +641 -0
- package/modules/hall-ui/public/profile.html +85 -0
- package/modules/hall-ui/public/profile.js +236 -0
- package/modules/hall-ui/public/ranks.html +69 -0
- package/modules/hall-ui/public/ranks.js +206 -0
- package/modules/hall-ui/public/roadmap.html +209 -0
- package/modules/hall-ui/public/roadmap.js +375 -0
- package/modules/hall-ui/public/sessions.html +92 -0
- package/modules/hall-ui/public/sessions.js +222 -0
- package/modules/hall-ui/public/settings.html +640 -0
- package/modules/hall-ui/public/settings.js +1219 -0
- package/modules/hall-ui/public/shell.js +495 -0
- package/modules/hall-ui/public/style.css +1748 -0
- package/modules/hall-ui/public/task-detail.js +253 -0
- package/modules/hall-ui/public/task.html +83 -0
- package/modules/hall-ui/public/task.js +326 -0
- package/modules/hall-ui/public/watch.html +333 -0
- package/modules/hall-ui/public/watch.js +859 -0
- package/modules/hall-ui/public/work.html +469 -0
- package/modules/hall-ui/public/work.js +1292 -0
- package/modules/ideas/CLAUDE.md +37 -0
- package/modules/ideas/blockers.js +100 -0
- package/modules/ideas/inbox.js +459 -0
- package/modules/ideas/module.json +10 -0
- package/modules/ideas/routes/blockers.js +120 -0
- package/modules/ideas/routes/inbox.js +230 -0
- package/modules/lifecycle/CLAUDE.md +63 -0
- package/modules/lifecycle/conflict-resolve.js +436 -0
- package/modules/lifecycle/db.js +4970 -0
- package/modules/lifecycle/done-when.js +355 -0
- package/modules/lifecycle/github-push.js +969 -0
- package/modules/lifecycle/goal-conflicts.js +133 -0
- package/modules/lifecycle/hierarchy.js +104 -0
- package/modules/lifecycle/lifecycle.js +58 -0
- package/modules/lifecycle/merge-lock.js +261 -0
- package/modules/lifecycle/migrations/lifecycle_001_goal_first_leads.sql +28 -0
- package/modules/lifecycle/migrations/lifecycle_002_backfill_orphan_task_goals.sql +36 -0
- package/modules/lifecycle/module.json +13 -0
- package/modules/lifecycle/pollers/publish-reconciler.js +20 -0
- package/modules/lifecycle/publish-reconciler.js +418 -0
- package/modules/lifecycle/routes/analytics.js +63 -0
- package/modules/lifecycle/routes/claims.js +506 -0
- package/modules/lifecycle/routes/dependencies.js +82 -0
- package/modules/lifecycle/routes/done-when.js +136 -0
- package/modules/lifecycle/routes/gate-approvals.js +197 -0
- package/modules/lifecycle/routes/goals.js +1419 -0
- package/modules/lifecycle/routes/lifecycle.js +43 -0
- package/modules/lifecycle/routes/tasks.js +1703 -0
- package/modules/lifecycle/routes/versions.js +128 -0
- package/modules/lifecycle/ship-card.js +505 -0
- package/modules/lifecycle/ship-preflight.js +225 -0
- package/modules/lifecycle/task-classifier.js +512 -0
- package/modules/lifecycle/version-close.js +106 -0
- package/modules/memory/CLAUDE.md +40 -0
- package/modules/memory/learnings.js +275 -0
- package/modules/memory/memory.js +572 -0
- package/modules/memory/module.json +12 -0
- package/modules/memory/routes/learnings.js +90 -0
- package/modules/memory/routes/memory.js +916 -0
- package/modules/memory/routes/search.js +188 -0
- package/modules/memory/search-chunker.js +252 -0
- package/modules/memory/search.js +320 -0
- package/modules/onboarding/CLAUDE.md +55 -0
- package/modules/onboarding/approval-broadcast.js +59 -0
- package/modules/onboarding/db.js +54 -0
- package/modules/onboarding/module.json +10 -0
- package/modules/onboarding/newcomer-restock.js +212 -0
- package/modules/onboarding/onboarding-state.js +532 -0
- package/modules/onboarding/onboarding.js +508 -0
- package/modules/onboarding/port.js +63 -0
- package/modules/onboarding/routes/access-requests.js +208 -0
- package/modules/onboarding/routes/onboarding.js +54 -0
- package/modules/security/CLAUDE.md +44 -0
- package/modules/security/module.json +10 -0
- package/modules/security/routes/reports.js +251 -0
- package/modules/security/secret-scrub.js +201 -0
- package/modules/security/security.js +294 -0
- package/modules/sessions/CLAUDE.md +39 -0
- package/modules/sessions/bfg.js +357 -0
- package/modules/sessions/db.js +326 -0
- package/modules/sessions/module.json +10 -0
- package/modules/sessions/pulse.js +149 -0
- package/modules/sessions/routes/sessions.js +331 -0
- package/modules/status-ui/CLAUDE.md +47 -0
- package/modules/status-ui/module.json +14 -0
- package/modules/status-ui/public/README.md +29 -0
- package/modules/status-ui/public/cursors/arrow.png +0 -0
- package/modules/status-ui/public/cursors/hand-pointer.png +0 -0
- package/modules/status-ui/public/index.html +363 -0
- package/modules/status-ui/public/snapshots/.gitkeep +0 -0
- package/modules/status-ui/public/status.js +1375 -0
- package/modules/status-ui/public/style.css +1342 -0
- package/package-lock.json +975 -0
- package/package.json +28 -0
- package/public-docs/index.html +35 -0
- package/public-docs/redoc.standalone.js +1838 -0
- package/public-docs/redoc.standalone.js.LICENSE.txt +102 -0
- package/public-landing/buddha.png +0 -0
- package/public-landing/create.html +868 -0
- package/public-landing/index.html +168 -0
- package/scripts/bongos-mcp.js +28 -0
- package/scripts/cron/style_guide_audit.sh +134 -0
- package/scripts/discord/README.md +52 -0
- package/scripts/discord/lib.js +80 -0
- package/scripts/discord/post.js +51 -0
- package/scripts/gds/CLAUDE.md +144 -0
- package/scripts/gds/abandon.js +58 -0
- package/scripts/gds/adopt-detect.js +123 -0
- package/scripts/gds/adopt-import.js +111 -0
- package/scripts/gds/adopt-preflight.js +121 -0
- package/scripts/gds/api.js +78 -0
- package/scripts/gds/architect-audit.js +288 -0
- package/scripts/gds/art-stage.js +152 -0
- package/scripts/gds/audit-authorship.sh +243 -0
- package/scripts/gds/audit-deps.js +121 -0
- package/scripts/gds/audit-exports.js +128 -0
- package/scripts/gds/audit-replay.sh +161 -0
- package/scripts/gds/audit-ships.js +251 -0
- package/scripts/gds/autonomy-gate.js +135 -0
- package/scripts/gds/backfill-cost.js +165 -0
- package/scripts/gds/backfill-sessions.js +351 -0
- package/scripts/gds/backfill-task-rewards.js +408 -0
- package/scripts/gds/backup.js +94 -0
- package/scripts/gds/bfg-principal-token.js +65 -0
- package/scripts/gds/box-auth-check.js +160 -0
- package/scripts/gds/box-connect-lib.js +453 -0
- package/scripts/gds/box.js +1258 -0
- package/scripts/gds/build-bongos.js +94 -0
- package/scripts/gds/capture.js +123 -0
- package/scripts/gds/cf-tunnel.js +419 -0
- package/scripts/gds/ci-grade.js +82 -0
- package/scripts/gds/claim-recall.js +112 -0
- package/scripts/gds/claim.js +333 -0
- package/scripts/gds/classify-discipline.js +153 -0
- package/scripts/gds/claude-materialize.js +176 -0
- package/scripts/gds/cleanup-orphans.js +74 -0
- package/scripts/gds/cli-lib.js +518 -0
- package/scripts/gds/code.js +101 -0
- package/scripts/gds/codemod-rename-src-gds.js +145 -0
- package/scripts/gds/conductor-dispatch.js +426 -0
- package/scripts/gds/context-pack.js +228 -0
- package/scripts/gds/coreB-carve-proof.sh +716 -0
- package/scripts/gds/cost.js +47 -0
- package/scripts/gds/db-backup-nightly.sh +160 -0
- package/scripts/gds/db-backup-offsite.sh +152 -0
- package/scripts/gds/demote.js +40 -0
- package/scripts/gds/design-sync.js +62 -0
- package/scripts/gds/diagnose-achievements.js +118 -0
- package/scripts/gds/diagram-facts.js +319 -0
- package/scripts/gds/discipline-modes.json +25 -0
- package/scripts/gds/discord-channels.js +66 -0
- package/scripts/gds/do-api.js +176 -0
- package/scripts/gds/docs-entropy.js +403 -0
- package/scripts/gds/doctor.js +389 -0
- package/scripts/gds/feedback-latest.js +94 -0
- package/scripts/gds/fetch-art-key.js +106 -0
- package/scripts/gds/fetch-sound-prefs.js +88 -0
- package/scripts/gds/figma-design-sync.js +61 -0
- package/scripts/gds/fitness.js +1446 -0
- package/scripts/gds/gate-review.js +322 -0
- package/scripts/gds/gating.js +325 -0
- package/scripts/gds/gds-db-backup.service +24 -0
- package/scripts/gds/gds-db-backup.timer +20 -0
- package/scripts/gds/gds-db-offsite.service +29 -0
- package/scripts/gds/gds-db-offsite.timer +18 -0
- package/scripts/gds/gen-api-client.js +508 -0
- package/scripts/gds/gen-api-docs.js +1037 -0
- package/scripts/gds/gen-atlas.js +366 -0
- package/scripts/gds/gen-diagrams.js +608 -0
- package/scripts/gds/gen-file-map.js +196 -0
- package/scripts/gds/gen-repo-map.js +519 -0
- package/scripts/gds/gen-session-index.js +210 -0
- package/scripts/gds/git-merge-regen.js +147 -0
- package/scripts/gds/grade-smoke.js +205 -0
- package/scripts/gds/hooks/pre-push.js +169 -0
- package/scripts/gds/http-api-client.js +88 -0
- package/scripts/gds/init.js +903 -0
- package/scripts/gds/install-git-hooks.js +114 -0
- package/scripts/gds/key-card.js +213 -0
- package/scripts/gds/key-status.js +126 -0
- package/scripts/gds/land-watch.js +168 -0
- package/scripts/gds/learning-capture.js +145 -0
- package/scripts/gds/lib.sh +44 -0
- package/scripts/gds/linkify-refs.js +473 -0
- package/scripts/gds/local-preview-lib.js +159 -0
- package/scripts/gds/local-preview.js +95 -0
- package/scripts/gds/login.js +225 -0
- package/scripts/gds/main-audit.js +279 -0
- package/scripts/gds/me.js +88 -0
- package/scripts/gds/memory-map.js +174 -0
- package/scripts/gds/merge-lock.js +71 -0
- package/scripts/gds/migration-namespace.js +200 -0
- package/scripts/gds/mirror-lineage-check.js +319 -0
- package/scripts/gds/mirror-redact.js +305 -0
- package/scripts/gds/module.js +482 -0
- package/scripts/gds/newcomer-floor.config.json +13 -0
- package/scripts/gds/newcomer-floor.js +415 -0
- package/scripts/gds/newcomer-floor.templates.json +86 -0
- package/scripts/gds/newcomer-floor.test.js +235 -0
- package/scripts/gds/next-number.js +163 -0
- package/scripts/gds/oauth-secret.js +197 -0
- package/scripts/gds/onboard.js +217 -0
- package/scripts/gds/onboarding-analyze.js +226 -0
- package/scripts/gds/onboarding-config.js +178 -0
- package/scripts/gds/onboarding-ship.js +92 -0
- package/scripts/gds/package-core.js +684 -0
- package/scripts/gds/paste-token.js +131 -0
- package/scripts/gds/portability-smoke-art.sh +85 -0
- package/scripts/gds/portability-smoke-memory.sh +147 -0
- package/scripts/gds/provision.js +1324 -0
- package/scripts/gds/publish-manifest.js +307 -0
- package/scripts/gds/pulse.js +309 -0
- package/scripts/gds/push-main.js +45 -0
- package/scripts/gds/read-session-export.js +359 -0
- package/scripts/gds/reauth.js +149 -0
- package/scripts/gds/recall.js +135 -0
- package/scripts/gds/release.js +71 -0
- package/scripts/gds/render-ideas.js +173 -0
- package/scripts/gds/repo-metrics.js +363 -0
- package/scripts/gds/resolve-skill-model.js +57 -0
- package/scripts/gds/run-routine.js +197 -0
- package/scripts/gds/run-unit-tests.js +178 -0
- package/scripts/gds/sandbox-stage.js +653 -0
- package/scripts/gds/search-ingest-db.js +209 -0
- package/scripts/gds/search-ingest-md.js +225 -0
- package/scripts/gds/security-baseline-check.sh +180 -0
- package/scripts/gds/seed-adopt-tasks.js +75 -0
- package/scripts/gds/seed-api-contract-goal-tasks.js +141 -0
- package/scripts/gds/seed-bongos-app-tasks.js +294 -0
- package/scripts/gds/seed-bongos-coreB-tranche1-tasks.js +81 -0
- package/scripts/gds/seed-bongos-coreB-tranche2-tasks.js +100 -0
- package/scripts/gds/seed-bongos-goal-hierarchy-tasks.js +307 -0
- package/scripts/gds/seed-bongos-module-system-tasks.js +346 -0
- package/scripts/gds/seed-bongos-tasks.js +214 -0
- package/scripts/gds/seed-gds-v4-tasks.js +624 -0
- package/scripts/gds/seed-governance-tasks.js +340 -0
- package/scripts/gds/seed-medusa-tasks.js +440 -0
- package/scripts/gds/seed-module-upstreaming.js +219 -0
- package/scripts/gds/seed-productization-tasks.js +235 -0
- package/scripts/gds/seed-provisioning-tasks.js +83 -0
- package/scripts/gds/seed-staging.sh +104 -0
- package/scripts/gds/seed-v2-world-tasks.js +283 -0
- package/scripts/gds/seed-v3-tasks.js +1981 -0
- package/scripts/gds/session-cost.js +393 -0
- package/scripts/gds/session-digest.js +179 -0
- package/scripts/gds/session-eval.js +576 -0
- package/scripts/gds/session.js +404 -0
- package/scripts/gds/setup.js +504 -0
- package/scripts/gds/shell.js +100 -0
- package/scripts/gds/ship.js +3618 -0
- package/scripts/gds/smoke-batch-claim.sh +213 -0
- package/scripts/gds/smoke-batch-release.sh +296 -0
- package/scripts/gds/smoke-box.sh +150 -0
- package/scripts/gds/smoke-claim-consistency.sh +212 -0
- package/scripts/gds/smoke-dependencies.sh +84 -0
- package/scripts/gds/smoke-gds.js +125 -0
- package/scripts/gds/smoke-write.sh +168 -0
- package/scripts/gds/start.js +782 -0
- package/scripts/gds/status.js +365 -0
- package/scripts/gds/triage.js +366 -0
- package/scripts/gds/upgrade.js +421 -0
- package/scripts/gds/validate-design.js +254 -0
- package/scripts/gds/version-close.js +549 -0
- package/scripts/migrate.sh +183 -0
- package/scripts/migrate_cost_ledger.sh +178 -0
- package/scripts/public-mirror-export.js +291 -0
- package/scripts/render-diagrams.sh +88 -0
- package/scripts/security/dep-audit.js +276 -0
- package/scripts/setup-builder.js +274 -0
- package/scripts/setup-builder.sh +28 -0
- package/scripts/snapshots/backfill-history.mjs +292 -0
- package/scripts/snapshots/maybe-render-on-deploy.sh +66 -0
- package/scripts/snapshots/render-pre-art-eras.mjs +329 -0
- package/scripts/snapshots/render-world.mjs +322 -0
- package/scripts/status-mirror-sync.js +192 -0
- package/scripts/status-probe.mjs +185 -0
- package/server.js +212 -0
- package/src/asset-version.js +71 -0
- package/src/bongos/CLAUDE.md +64 -0
- package/src/bongos/api-errors.js +281 -0
- package/src/bongos/api-path-404.js +67 -0
- package/src/bongos/api-prefix.js +38 -0
- package/src/bongos/app-pair.js +172 -0
- package/src/bongos/auth.js +979 -0
- package/src/bongos/cascade-dispatch.js +123 -0
- package/src/bongos/db-kernel.js +252 -0
- package/src/bongos/db.js +911 -0
- package/src/bongos/goal-scope-check.js +120 -0
- package/src/bongos/hierarchy-config.js +189 -0
- package/src/bongos/live-channel.js +145 -0
- package/src/bongos/llm-cache.js +260 -0
- package/src/bongos/llm-pricing.js +220 -0
- package/src/bongos/metrics.js +286 -0
- package/src/bongos/middleware/audit.js +274 -0
- package/src/bongos/middleware/error-envelope.js +93 -0
- package/src/bongos/middleware/rate-limit.js +195 -0
- package/src/bongos/module-scope-map.js +271 -0
- package/src/bongos/optimizer.js +166 -0
- package/src/bongos/path-match.js +47 -0
- package/src/bongos/permission-path-check.js +248 -0
- package/src/bongos/pool.js +55 -0
- package/src/bongos/repo-info.js +91 -0
- package/src/bongos/route-rank-check.js +420 -0
- package/src/bongos/routes/CLAUDE.md +43 -0
- package/src/bongos/routes/_helpers.js +369 -0
- package/src/bongos/routes/audit-log.js +59 -0
- package/src/bongos/routes/auth.js +755 -0
- package/src/bongos/routes/backup.js +104 -0
- package/src/bongos/routes/builders.js +526 -0
- package/src/bongos/routes/healthz.js +16 -0
- package/src/bongos/routes/instance.js +129 -0
- package/src/bongos/routes/live.js +125 -0
- package/src/bongos/routes/llm-cache.js +119 -0
- package/src/bongos/routes/me.js +380 -0
- package/src/bongos/routes/modules.js +131 -0
- package/src/bongos/routes/optimizer.js +54 -0
- package/src/bongos/routes/public.js +476 -0
- package/src/bongos/routes/security.js +207 -0
- package/src/bongos/routes.js +166 -0
- package/src/bongos/secret-box.js +177 -0
- package/src/bongos/sensitive-surfaces.js +30 -0
- package/src/bongos/serve-internal.js +889 -0
- package/src/bongos/uptime-poller.js +90 -0
- package/src/bongos-downloads.js +70 -0
- package/src/branding.js +182 -0
- package/src/build-info.js +77 -0
- package/src/devbox-downloads.js +67 -0
- package/src/instance-config.js +200 -0
- package/src/module-api.js +366 -0
- package/src/module-loader/loader.js +268 -0
- package/src/module-loader/manifest-schema.js +140 -0
- package/src/module-loader/provenance.js +28 -0
- package/src/module-loader/semver.js +100 -0
- package/src/module-seams.js +291 -0
- package/src/modules.js +254 -0
- package/src/platform-server.js +135 -0
- package/src/ui/adapters/claude-design/index.js +333 -0
- package/src/ui/adapters/figma/index.js +328 -0
- package/tests/abandon_task.mjs +138 -0
- package/tests/achievements_reconcile.mjs +167 -0
- package/tests/adopt_detect.mjs +141 -0
- package/tests/adopt_import.mjs +132 -0
- package/tests/adopt_preflight.mjs +105 -0
- package/tests/anti_grid.mjs +89 -0
- package/tests/api_client.mjs +231 -0
- package/tests/api_docs.mjs +283 -0
- package/tests/api_error_envelope_buildcheck.mjs +56 -0
- package/tests/api_error_envelope_middleware.mjs +99 -0
- package/tests/api_error_registry.mjs +108 -0
- package/tests/api_pagination_contract.mjs +51 -0
- package/tests/api_path_404.mjs +96 -0
- package/tests/app_pair.mjs +168 -0
- package/tests/architect.mjs +140 -0
- package/tests/art_key.mjs +121 -0
- package/tests/art_stage.mjs +121 -0
- package/tests/asset_version.mjs +94 -0
- package/tests/atlas.mjs +69 -0
- package/tests/audit_deps.mjs +74 -0
- package/tests/audit_middleware.mjs +385 -0
- package/tests/auth_admission_gate.mjs +164 -0
- package/tests/auth_anthropic_identity.mjs +95 -0
- package/tests/auth_duplicate_cookie.mjs +56 -0
- package/tests/auth_error_brand.mjs +77 -0
- package/tests/auth_flow_slice1.mjs +223 -0
- package/tests/auth_inactive_builder.mjs +94 -0
- package/tests/auth_page_gate.mjs +92 -0
- package/tests/auth_session_ttl.mjs +155 -0
- package/tests/autonomous_fit_protected_paths.mjs +115 -0
- package/tests/autonomy_gate.mjs +85 -0
- package/tests/autotile_variants.mjs +256 -0
- package/tests/autotile_xverify.mjs +133 -0
- package/tests/backfill_sessions.mjs +101 -0
- package/tests/backfill_task_rewards.mjs +152 -0
- package/tests/backup_cli.mjs +104 -0
- package/tests/bfg.mjs +327 -0
- package/tests/bfg_write.mjs +423 -0
- package/tests/bongos_box_connect.mjs +89 -0
- package/tests/bongos_dispatch.mjs +74 -0
- package/tests/bongos_downloads.mjs +118 -0
- package/tests/bongos_login.mjs +34 -0
- package/tests/box_access.mjs +186 -0
- package/tests/box_auth_check.mjs +114 -0
- package/tests/box_code_staleness.mjs +116 -0
- package/tests/box_connect_e2e.mjs +196 -0
- package/tests/box_cost.mjs +70 -0
- package/tests/box_dns_repoint.mjs +151 -0
- package/tests/box_host_keys.mjs +102 -0
- package/tests/box_onboard.mjs +317 -0
- package/tests/box_scope_session.mjs +115 -0
- package/tests/box_ship_permissions.mjs +71 -0
- package/tests/box_terminal_ssrf.mjs +90 -0
- package/tests/boxes.mjs +1255 -0
- package/tests/branding.mjs +88 -0
- package/tests/bug_attachments.mjs +189 -0
- package/tests/build_info.mjs +88 -0
- package/tests/builder_needs.mjs +99 -0
- package/tests/cap_newcomer_tasks.mjs +114 -0
- package/tests/cascade-dispatch.mjs +95 -0
- package/tests/cf_tunnel.mjs +459 -0
- package/tests/claim_recall.mjs +150 -0
- package/tests/claims_bind_session.mjs +47 -0
- package/tests/claude_materialize.mjs +148 -0
- package/tests/cli_box_scope.mjs +52 -0
- package/tests/cli_lib_formatters.mjs +87 -0
- package/tests/cli_token_reissue.mjs +147 -0
- package/tests/client_bundle_route.mjs +47 -0
- package/tests/cloud_bongos_pack.mjs +85 -0
- package/tests/conductor.mjs +496 -0
- package/tests/confirm_attribution.mjs +156 -0
- package/tests/conflict_resolve.mjs +462 -0
- package/tests/context_pack.mjs +240 -0
- package/tests/cost.mjs +94 -0
- package/tests/cost_audit.mjs +122 -0
- package/tests/cost_budget_status.mjs +78 -0
- package/tests/cost_reconcile.mjs +102 -0
- package/tests/cost_summary_shape.mjs +94 -0
- package/tests/credit_grant.mjs +376 -0
- package/tests/criterion_progress.mjs +220 -0
- package/tests/criterion_proposal.mjs +109 -0
- package/tests/criterion_ref_parse.mjs +53 -0
- package/tests/currency_label.mjs +119 -0
- package/tests/day_cycle.mjs +197 -0
- package/tests/dependencies_migration.mjs +77 -0
- package/tests/dependencies_polymorphic.mjs +212 -0
- package/tests/deploy_instance.mjs +78 -0
- package/tests/devbox_downloads.mjs +122 -0
- package/tests/diff_derived_signals.mjs +115 -0
- package/tests/discord_approvals.mjs +174 -0
- package/tests/discord_autojoin.mjs +158 -0
- package/tests/discord_bot.mjs +94 -0
- package/tests/discord_bugs.mjs +297 -0
- package/tests/discord_channels.mjs +337 -0
- package/tests/discord_foundation.mjs +76 -0
- package/tests/discord_inbound.mjs +190 -0
- package/tests/discord_message_buffer.mjs +87 -0
- package/tests/discord_oauth.mjs +55 -0
- package/tests/discord_reaction.mjs +170 -0
- package/tests/discord_role_sync.mjs +151 -0
- package/tests/display_name.mjs +123 -0
- package/tests/dns_preflight.mjs +74 -0
- package/tests/event_sound_prefs.mjs +194 -0
- package/tests/eviction_policy.mjs +279 -0
- package/tests/figma_adapter.mjs +199 -0
- package/tests/file_map.mjs +91 -0
- package/tests/first_admin_bootstrap.mjs +40 -0
- package/tests/fitness.mjs +435 -0
- package/tests/fitness_two_root.mjs +104 -0
- package/tests/fixtures/session-506-digest.json +34 -0
- package/tests/gate_approvals.mjs +451 -0
- package/tests/gate_review.mjs +152 -0
- package/tests/git_merge_regen.mjs +151 -0
- package/tests/github_push_ancestry.mjs +119 -0
- package/tests/github_push_land_proof.mjs +127 -0
- package/tests/github_push_merge_driver.mjs +175 -0
- package/tests/goal_abuse_matrix.mjs +187 -0
- package/tests/goal_achievement.mjs +146 -0
- package/tests/goal_conflicts.mjs +305 -0
- package/tests/goal_dependencies_read.mjs +73 -0
- package/tests/goal_member_roles.mjs +420 -0
- package/tests/goal_membership_requests.mjs +332 -0
- package/tests/goal_routes.mjs +203 -0
- package/tests/goal_scope_check.mjs +167 -0
- package/tests/goal_tier.mjs +312 -0
- package/tests/goal_wall_proof.mjs +112 -0
- package/tests/grade_attempts.mjs +73 -0
- package/tests/grade_attribution.mjs +117 -0
- package/tests/grade_diff_hash.mjs +191 -0
- package/tests/grade_server_authoritative.mjs +305 -0
- package/tests/grade_unavailable_display.mjs +66 -0
- package/tests/grader.mjs +1960 -0
- package/tests/grader_bypass.mjs +57 -0
- package/tests/grader_panel.mjs +252 -0
- package/tests/grader_prompt_fencing.mjs +192 -0
- package/tests/helpers.mjs +52 -0
- package/tests/hierarchy-config.mjs +81 -0
- package/tests/init.mjs +463 -0
- package/tests/instance_config.mjs +76 -0
- package/tests/instance_manifest.mjs +110 -0
- package/tests/interaction_prefs.mjs +72 -0
- package/tests/key-card.mjs +110 -0
- package/tests/key-status.mjs +45 -0
- package/tests/land_watch.mjs +115 -0
- package/tests/lifecycle-card.mjs +145 -0
- package/tests/lifecycle_breakdown_view.mjs +236 -0
- package/tests/linkify_refs.mjs +264 -0
- package/tests/live_channel.mjs +128 -0
- package/tests/live_sse.mjs +122 -0
- package/tests/llm-cache-route.mjs +110 -0
- package/tests/llm-cache.mjs +140 -0
- package/tests/llm-pricing.mjs +152 -0
- package/tests/local_preview.mjs +136 -0
- package/tests/main_audit.mjs +137 -0
- package/tests/memory_isolation.mjs +431 -0
- package/tests/memory_map.mjs +107 -0
- package/tests/memory_pull.mjs +429 -0
- package/tests/merge_lease.mjs +100 -0
- package/tests/merge_lock.mjs +312 -0
- package/tests/metrics.mjs +147 -0
- package/tests/migration_allocation.mjs +146 -0
- package/tests/migration_namespace.mjs +185 -0
- package/tests/migrations_fresh_replay_guard.mjs +38 -0
- package/tests/mirror_lineage_fulltree.mjs +45 -0
- package/tests/mirror_lineage_proof.mjs +72 -0
- package/tests/mirror_redact.mjs +193 -0
- package/tests/model_alloc_prefs.mjs +63 -0
- package/tests/module-scope-map.mjs +88 -0
- package/tests/module_api.mjs +110 -0
- package/tests/module_cli.mjs +292 -0
- package/tests/module_contributions.mjs +148 -0
- package/tests/module_loader.mjs +286 -0
- package/tests/module_manifest.mjs +194 -0
- package/tests/module_owned_tests.mjs +105 -0
- package/tests/module_provenance.mjs +34 -0
- package/tests/module_route_rank.mjs +147 -0
- package/tests/module_seams.mjs +68 -0
- package/tests/modules.mjs +156 -0
- package/tests/modules_route.mjs +38 -0
- package/tests/newcomer_floor.mjs +17 -0
- package/tests/newcomer_restock.mjs +80 -0
- package/tests/next_number.mjs +154 -0
- package/tests/oauth_secret.mjs +60 -0
- package/tests/onboard.mjs +70 -0
- package/tests/onboarding_gate.mjs +267 -0
- package/tests/onboarding_observability.mjs +344 -0
- package/tests/onboarding_route_signals.mjs +175 -0
- package/tests/onboarding_state.mjs +346 -0
- package/tests/one_active_claim_per_session.mjs +111 -0
- package/tests/orphaned_claim_ship.mjs +135 -0
- package/tests/override_attribution.mjs +142 -0
- package/tests/override_request_ownership.mjs +235 -0
- package/tests/package_core.mjs +435 -0
- package/tests/parallel-load.mjs +402 -0
- package/tests/path_match.mjs +62 -0
- package/tests/permission_path.mjs +151 -0
- package/tests/platform_boot.mjs +167 -0
- package/tests/pre_compact.mjs +60 -0
- package/tests/preview_server.mjs +127 -0
- package/tests/profile_route.mjs +197 -0
- package/tests/provision.mjs +805 -0
- package/tests/provisioning_github_app_routes.mjs +98 -0
- package/tests/public_mirror_export.mjs +124 -0
- package/tests/public_task_resolver.mjs +112 -0
- package/tests/publish_branch_route.mjs +422 -0
- package/tests/publish_manifest.mjs +184 -0
- package/tests/publish_reconciler.mjs +728 -0
- package/tests/rank_gate.mjs +463 -0
- package/tests/rate_limit_public.mjs +217 -0
- package/tests/read_session_export.mjs +89 -0
- package/tests/recall_cli.mjs +82 -0
- package/tests/recall_hints.mjs +119 -0
- package/tests/recent_shipped_window.mjs +80 -0
- package/tests/regrade_eligibility.mjs +202 -0
- package/tests/render_prefs.mjs +122 -0
- package/tests/repo_map.mjs +92 -0
- package/tests/reward_gate.mjs +284 -0
- package/tests/route_rank_check.mjs +243 -0
- package/tests/route_validation.mjs +490 -0
- package/tests/routine_timers.mjs +72 -0
- package/tests/rubric_fixtures.mjs +140 -0
- package/tests/run_routine.mjs +69 -0
- package/tests/sandbox_stage.mjs +582 -0
- package/tests/scrubber_corpus.mjs +257 -0
- package/tests/search_chunker.mjs +209 -0
- package/tests/search_ingest.mjs +96 -0
- package/tests/search_ingest_db.mjs +88 -0
- package/tests/search_isolation.mjs +204 -0
- package/tests/search_route.mjs +95 -0
- package/tests/secret_box.mjs +189 -0
- package/tests/security_docs.mjs +186 -0
- package/tests/security_route.mjs +108 -0
- package/tests/served_paths_shipped.mjs +118 -0
- package/tests/session-cards.mjs +142 -0
- package/tests/session_506_regression.mjs +69 -0
- package/tests/session_caps.mjs +238 -0
- package/tests/session_cost.mjs +133 -0
- package/tests/session_digest.mjs +73 -0
- package/tests/session_eval.mjs +386 -0
- package/tests/session_index.mjs +105 -0
- package/tests/session_records.mjs +369 -0
- package/tests/session_search.mjs +78 -0
- package/tests/session_start_freshness.mjs +123 -0
- package/tests/setup_smoke.mjs +154 -0
- package/tests/ship_autolinkify.mjs +60 -0
- package/tests/ship_automerge_injection.mjs +174 -0
- package/tests/ship_broadcast_routing.mjs +125 -0
- package/tests/ship_broadcast_server.mjs +175 -0
- package/tests/ship_card.mjs +280 -0
- package/tests/ship_ci_deploy.mjs +374 -0
- package/tests/ship_preflight.mjs +368 -0
- package/tests/ship_premerge.mjs +75 -0
- package/tests/ship_resume.mjs +129 -0
- package/tests/ship_upload_skip.mjs +89 -0
- package/tests/ship_zero_commit.mjs +60 -0
- package/tests/single_host_hall.mjs +98 -0
- package/tests/skill_prefs.mjs +150 -0
- package/tests/smoke_preflight.mjs +66 -0
- package/tests/snapshot_picker.mjs +233 -0
- package/tests/sound_prefs.mjs +198 -0
- package/tests/sprite_facing.mjs +35 -0
- package/tests/status-probe.mjs +89 -0
- package/tests/status_backfill.mjs +94 -0
- package/tests/stop_refcheck.mjs +127 -0
- package/tests/streak.mjs +175 -0
- package/tests/subdomain_auth.mjs +58 -0
- package/tests/task_classifier_vocab.mjs +41 -0
- package/tests/task_create_idempotency.mjs +96 -0
- package/tests/task_goal_link.mjs +93 -0
- package/tests/task_ownership_gate.mjs +125 -0
- package/tests/terrain_picker.mjs +298 -0
- package/tests/touches_overlap.mjs +75 -0
- package/tests/triage_development.mjs +140 -0
- package/tests/turn_in_place.mjs +280 -0
- package/tests/upgrade.mjs +357 -0
- package/tests/verify_task_guard.mjs +64 -0
- package/tests/version_close.mjs +76 -0
- package/tests/versions_route.mjs +93 -0
- package/tests/walkability.mjs +188 -0
- package/tests/wandering_prefs.mjs +176 -0
- package/tests/worktree_disambiguation.mjs +105 -0
- package/tests/worktree_guard.mjs +90 -0
|
@@ -0,0 +1,1703 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
// Intra-module + the published doorway — a module never requires a core internal.
|
|
6
|
+
const api = require('../../../src/module-api');
|
|
7
|
+
const auth = api;
|
|
8
|
+
const db = require('../db');
|
|
9
|
+
const hierarchy = require('../hierarchy');
|
|
10
|
+
// grading carved to modules/grading/ (BV1.R73 / ADR 0091 §4): the lifecycle no
|
|
11
|
+
// longer imports the grader — it resolves the `grade` kernel PORT at grade time
|
|
12
|
+
// (seams.resolveOptional('grade'), in POST /tasks/:id/grade below).
|
|
13
|
+
const githubPush = require('../github-push');
|
|
14
|
+
const seams = api;
|
|
15
|
+
const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
|
|
16
|
+
const { isModuleKey, moduleKeys } = api.moduleScopeMap;
|
|
17
|
+
const { validateOrRespond, LIMITS, parseId } = api;
|
|
18
|
+
// The onboarding state machine + the evergreen newcomer-chore restock carved into
|
|
19
|
+
// modules/onboarding/ (BV1.R81 / ADR 0093 §1): GET /tasks/claimable marks
|
|
20
|
+
// first_start and POST /tasks/newcomer-restock tops up the chore shelf — both
|
|
21
|
+
// through the `onboarding` kernel port (seams.resolveOptional('onboarding')),
|
|
22
|
+
// never a direct require of a module file.
|
|
23
|
+
const { pool } = api;
|
|
24
|
+
const { branding } = api;
|
|
25
|
+
|
|
26
|
+
// ---- server-mediated branch publish (ADR 0055 / task 1025) -----------------
|
|
27
|
+
// Body caps for POST /tasks/:id/publish-branch. The branch arrives as a base64
|
|
28
|
+
// git bundle; base64 inflates ~33%, so the encoded string + JSON body limit sit
|
|
29
|
+
// above the 20 MB decoded ceiling. The decoded bytes are re-checked in the
|
|
30
|
+
// handler (the JSON `limit` only bounds the ENCODED payload — SR-17 posture).
|
|
31
|
+
const PUBLISH_BODY_LIMIT = '32mb';
|
|
32
|
+
const PUBLISH_BUNDLE_B64_MAX = 30 * 1024 * 1024; // validator guard on the b64 string
|
|
33
|
+
const PUBLISH_BUNDLE_MAX_BYTES = 20 * 1024 * 1024; // 20 MB decoded bundle
|
|
34
|
+
|
|
35
|
+
// De-`#` text bound for a GitHub-rendered surface (PR title/body): a bare "#NNN"
|
|
36
|
+
// autolinks to GitHub's own issue space (dead links for GDS task ids — task 916).
|
|
37
|
+
// Mirrors ship.js sanitizeGitHubRefs (which lives in scripts/ and isn't requirable
|
|
38
|
+
// here).
|
|
39
|
+
function deHashRefs(text) {
|
|
40
|
+
return String(text == null ? '' : text).replace(/#(\d{1,5})\b/g, 'task $1');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// tasks.discipline — orthogonal to kind. See migrations 014 + 086.
|
|
44
|
+
// VALID_DISCIPLINES (engineer = builds code · artist = makes visual assets ·
|
|
45
|
+
// ideator = thinks/writes/scopes · unclassified = un-triaged) is imported above
|
|
46
|
+
// from ../task-classifier — the single source (D1).
|
|
47
|
+
|
|
48
|
+
// ?fit= on /tasks/claimable — narrows to tasks the autonomous overnight
|
|
49
|
+
// session-runner (#358) may pick up. See db.listClaimableTasks.
|
|
50
|
+
const VALID_FITS = new Set(['autonomous', 'parallel']);
|
|
51
|
+
|
|
52
|
+
// V3.R48 (#267): auto-detect schema-change tasks so the touches[] scanner
|
|
53
|
+
// reserves a migration number even when the builder forgets to list
|
|
54
|
+
// 'migrations/'. The keyword set is intentionally conservative — it matches
|
|
55
|
+
// whole tokens ("migration"/"migrations", "schema") and the two SQL phrases
|
|
56
|
+
// that almost always imply a migration, so unrelated words ("schematic",
|
|
57
|
+
// "reschema", a button color) don't false-positive. Case-insensitive.
|
|
58
|
+
//
|
|
59
|
+
// Migration-keyword detection. POST /tasks uses this to set the
|
|
60
|
+
// tasks.needs_migration boolean (migration 081, touches[] cleanup [1/8] /
|
|
61
|
+
// task 876), which is what the claim-time allocator now keys off — replacing
|
|
62
|
+
// the old "inject 'migrations/' into touches[]" sentinel. Conservative,
|
|
63
|
+
// whole-token, case-insensitive. The former scripts/gds/capture.js mirror was
|
|
64
|
+
// deleted with the same task: captured ideas have no touches[] and the server
|
|
65
|
+
// re-detects on create, so the CLI copy earned its keep no longer.
|
|
66
|
+
const MIGRATION_KEYWORD_RE =
|
|
67
|
+
/\bmigrations?\b|\bschema\b|\balter\s+table\b|\bcreate\s+table\b/i;
|
|
68
|
+
|
|
69
|
+
function detectsMigration(...texts) {
|
|
70
|
+
return texts.some((t) => typeof t === 'string' && MIGRATION_KEYWORD_RE.test(t));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- peer votes (V3.R25 #245) ---------------------------------------------
|
|
74
|
+
// The peer-vote SQL lives here (inline, via the route module's `pool`) rather
|
|
75
|
+
// than in db.js on purpose: it's a self-contained feature, declared in this
|
|
76
|
+
// task's touches[] (migrations/, this file, modules/hall-ui/), and keeping it
|
|
77
|
+
// out of the large shared db.js avoids a parallel-safety conflict on that file.
|
|
78
|
+
// Same deliberate-locality call as the MIGRATION_KEYWORD_RE mirror above.
|
|
79
|
+
|
|
80
|
+
// VOTABLE_TASK_STATUSES — a task is votable once it's verified work the project
|
|
81
|
+
// keeps (confirmed = past the grader, credits landed; or shipped = deployed);
|
|
82
|
+
// backlog/ready/completed are not. Imported above from ../task-classifier (D1).
|
|
83
|
+
|
|
84
|
+
// #853 (ADR 0041): temporary, default-OFF global grader bypass. When the env
|
|
85
|
+
// GRADER_BYPASS_ENABLED is exactly '1', the subagent quality grader is treated
|
|
86
|
+
// as unavailable and any builder may land work via /tasks/:id/confirm (normally
|
|
87
|
+
// archon-only). Read live on each request — flipping the env (+ redeploy) is the
|
|
88
|
+
// on/off switch; no caching, so turning it off re-arms the trust boundary at
|
|
89
|
+
// once. Exported so /me can surface the same flag to ship.js.
|
|
90
|
+
function graderBypassEnabled() {
|
|
91
|
+
return process.env.GRADER_BYPASS_ENABLED === '1';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// SECURITY (#871): claim-ownership gate for the lifecycle-advancing routes
|
|
95
|
+
// (/grade, /confirm, /ship). These are requireBuilder-only (the grade is meant
|
|
96
|
+
// to be an objective subagent output; the supervisor hot path must not need an
|
|
97
|
+
// Archon), which — combined with applyGrade/confirmTask crediting work — let any
|
|
98
|
+
// authenticated builder act on ANOTHER builder's task:
|
|
99
|
+
// * /grade (requireBuilder, no rank gate): a builder could POST a fabricated
|
|
100
|
+
// passing grade on someone else's `completed` task. Pre-#871 applyGrade then
|
|
101
|
+
// credited the CALLER → cross-builder credit theft + ship-attribution hijack
|
|
102
|
+
// + a full bypass of the quality grader.
|
|
103
|
+
// * /confirm: archon-only normally, but the GRADER_BYPASS killswitch (ON
|
|
104
|
+
// during the grader outage) relaxes it to ANY builder, so anyone could
|
|
105
|
+
// force-confirm anyone's `completed` work past the (absent) grader.
|
|
106
|
+
// * /ship: any-builder supervisor hot path (so /merge-mode can land the queue).
|
|
107
|
+
//
|
|
108
|
+
// Allow when: the caller is an Archon (full authority / --regrade); OR — for
|
|
109
|
+
// /ship only (allowSupervisor) — the caller is Metic, the trusted rank that runs
|
|
110
|
+
// /merge-mode to sweep the confirmed queue across builders; OR the caller is the
|
|
111
|
+
// task's most-recent claim holder (the builder who did the work).
|
|
112
|
+
//
|
|
113
|
+
// A NEVER-CLAIMED task (degenerate state, e.g. status set directly via SQL) is
|
|
114
|
+
// NOT an open door: the Archon and supervisor early-returns above already cover
|
|
115
|
+
// the legitimate actors who land such a task. By the time we read the claim
|
|
116
|
+
// holder, the caller is necessarily a non-Archon (and not a supervisor Metic) —
|
|
117
|
+
// so a missing claim must FAIL CLOSED with the same 403, not silently ALLOW.
|
|
118
|
+
// (L2 residual, red-team 2026-06-12: combined with the GRADER_BYPASS relaxation
|
|
119
|
+
// on /confirm, the old "no claim → ALLOW" let any builder confirm a claimless
|
|
120
|
+
// `completed` task and have the credit fall to themselves.) An Archon (or the
|
|
121
|
+
// /merge-mode supervisor) can still land a claimless task — they pass above.
|
|
122
|
+
// Returns true if allowed; otherwise writes a 403 and returns false.
|
|
123
|
+
async function gateTaskOwnership(req, res, taskId, { allowSupervisor = false } = {}) {
|
|
124
|
+
const rank = req.builder && req.builder.rank;
|
|
125
|
+
if (rank === 'archon') return true;
|
|
126
|
+
if (allowSupervisor && rank === 'metic') return true;
|
|
127
|
+
const holder = await db.getLastClaimHolder(taskId);
|
|
128
|
+
if (holder && String(holder.builder_id) === String(req.builder.id)) return true;
|
|
129
|
+
res.fail('not_your_task', { status: 403, message: 'Only the task\'s claim holder (or an Archon) may grade/confirm/ship it.' });
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// task 1053 (F): a verification task — one whose deliverable IS a verification
|
|
134
|
+
// (e.g. a hardware-verify run). Confirming one requires an explicit attestation
|
|
135
|
+
// that the verify actually passed, so a hardware-verify can't be confirmed on a
|
|
136
|
+
// passing code-grade before the verify was even performed (the #1027 trap).
|
|
137
|
+
// Keyed on the explicit kind (set by the planner), NOT a title regex — a task
|
|
138
|
+
// ABOUT verify work would false-positive on text. Pure + exported for tests.
|
|
139
|
+
function isVerifyTask(task) {
|
|
140
|
+
return !!task && task.kind === 'verify';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// task 1139 / 1169 (ADR 0058): the trustless server rank-attestation behind the
|
|
144
|
+
// gate-review CI check. Only the SERVER can set it — it holds the authenticated
|
|
145
|
+
// builder's server-resolved rank AND the App/PAT credential — so CI can trust it
|
|
146
|
+
// where a PR-author-controlled signal (a PR title) would be spoofable. A Metic+
|
|
147
|
+
// author's gate-author-trust=success clears a NEEDS_TRUST gate surface with no
|
|
148
|
+
// owner click; below Metic gets no attestation (the gate escalates → owner review).
|
|
149
|
+
//
|
|
150
|
+
// rankAttestsGateTrust is the PURE rank decision (no network) — exported for tests.
|
|
151
|
+
function rankAttestsGateTrust(rank) {
|
|
152
|
+
const { tierOf, MIN_TIER } = api.permissionPathCheck;
|
|
153
|
+
const tier = tierOf(rank);
|
|
154
|
+
return tier != null && tier >= MIN_TIER;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// postGateAuthorTrust — set (or skip) the gate-author-trust commit status for an
|
|
158
|
+
// author's rank on a pushed SHA. Shared by the server-mediated publish path
|
|
159
|
+
// (POST /tasks/:id/publish-branch) and the laptop ci-land path's attest endpoint
|
|
160
|
+
// (POST /tasks/:id/attest-gate). Best-effort + never throws: a failed/skipped
|
|
161
|
+
// status post leaves the PR un-attested, which ESCALATES (the safe direction — no
|
|
162
|
+
// rank bypass without a real server signal). Returns { attested, reason } for UX.
|
|
163
|
+
async function postGateAuthorTrust({ rank, sha, taskId }) {
|
|
164
|
+
if (!rankAttestsGateTrust(rank)) return { attested: false, reason: 'rank_below_metic' };
|
|
165
|
+
try {
|
|
166
|
+
const ok = await githubPush.setCommitStatus({
|
|
167
|
+
sha,
|
|
168
|
+
state: 'success',
|
|
169
|
+
context: 'gate-author-trust',
|
|
170
|
+
description: `Metic+ author (${rank}) — task ${taskId}`,
|
|
171
|
+
});
|
|
172
|
+
return { attested: !!ok, reason: ok ? null : 'status_post_failed' };
|
|
173
|
+
} catch (_) {
|
|
174
|
+
return { attested: false, reason: 'status_post_failed' };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function peerVoteNet(taskId) {
|
|
179
|
+
const { rows } = await pool.query(
|
|
180
|
+
`SELECT COALESCE(SUM(vote), 0)::int AS net, COUNT(*)::int AS total
|
|
181
|
+
FROM peer_votes WHERE target_task_id = $1`,
|
|
182
|
+
[taskId]
|
|
183
|
+
);
|
|
184
|
+
return { net: rows[0].net, total: rows[0].total };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// #514: bad_kind responses carried a `valid` array but no human-readable line,
|
|
188
|
+
// so a caller (or CLI that only prints .message) couldn't tell at a glance what
|
|
189
|
+
// went wrong — the #506 session burned a turn guessing kind=chore. Add a
|
|
190
|
+
// .message that spells out the allowed values while keeping the valid[] array.
|
|
191
|
+
function badKind(res) {
|
|
192
|
+
const valid = Array.from(VALID_KINDS);
|
|
193
|
+
return res.fail('bad_kind', { status: 400, message: `kind must be one of: ${valid.join(', ')}`, details: { valid } });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = function buildTasksRouter() {
|
|
197
|
+
const router = express.Router();
|
|
198
|
+
|
|
199
|
+
// rank: any-builder — reads (filtered task list); preserved per CRITICAL §4.
|
|
200
|
+
router.get('/tasks', auth.requireBuilder, async (req, res) => {
|
|
201
|
+
const { version, status, kind, discipline, goal_id } = req.query;
|
|
202
|
+
if (kind && !VALID_KINDS.has(kind)) {
|
|
203
|
+
return badKind(res);
|
|
204
|
+
}
|
|
205
|
+
if (discipline && !VALID_DISCIPLINES.has(discipline)) {
|
|
206
|
+
return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
|
|
207
|
+
}
|
|
208
|
+
// ?goal_id= filters tasks to one goal (ADR 0086 §3 / BV1.R57). A non-numeric
|
|
209
|
+
// value is a 400 rather than a silently-ignored filter.
|
|
210
|
+
let goalId;
|
|
211
|
+
if (goal_id !== undefined) {
|
|
212
|
+
goalId = Number(goal_id);
|
|
213
|
+
if (!Number.isInteger(goalId) || goalId <= 0) {
|
|
214
|
+
return res.fail('bad_goal_id', { status: 400, message: 'goal_id must be a positive integer.' });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const tasks = await db.listTasks({ versionId: version, status, kind, discipline, goalId });
|
|
218
|
+
res.json({ tasks });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// NOTE: /tasks/claimable must come BEFORE /tasks/:id or Express will
|
|
222
|
+
// route 'claimable' to the :id param.
|
|
223
|
+
// rank: any-builder — claimable feed (read); preserved per CRITICAL §4 (supervisor hot path).
|
|
224
|
+
// #766: the primer-read onboarding gate that once sat here was removed — the
|
|
225
|
+
// Xenos path no longer requires reading the citizen's primer before surveying
|
|
226
|
+
// the works. Newcomers browse the claimable queue straight away; db.listClaimableTasks
|
|
227
|
+
// still filters Xenos to newcomer_friendly rows on its own.
|
|
228
|
+
router.get('/tasks/claimable',
|
|
229
|
+
auth.requireBuilder,
|
|
230
|
+
async (req, res) => {
|
|
231
|
+
const { version, discipline, fit } = req.query;
|
|
232
|
+
if (discipline && !VALID_DISCIPLINES.has(discipline)) {
|
|
233
|
+
return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
|
|
234
|
+
}
|
|
235
|
+
if (fit && !VALID_FITS.has(fit)) {
|
|
236
|
+
return res.fail('bad_fit', 400, { valid: Array.from(VALID_FITS) });
|
|
237
|
+
}
|
|
238
|
+
const [claimable, active] = await Promise.all([
|
|
239
|
+
db.listClaimableTasks({
|
|
240
|
+
versionId: version || null,
|
|
241
|
+
builderId: req.builder.id,
|
|
242
|
+
discipline: discipline || null,
|
|
243
|
+
fit: fit || null,
|
|
244
|
+
}),
|
|
245
|
+
db.listActiveClaims(),
|
|
246
|
+
]);
|
|
247
|
+
// V3.R75 (#294): tick the onboarding state machine for first_start. Fire
|
|
248
|
+
// and forget — the response should never wait on or fail because of the
|
|
249
|
+
// onboarding write. safeMarkStage swallows errors and handles the
|
|
250
|
+
// graduation broadcast. Through the `onboarding` kernel port (BV1.R81 / ADR
|
|
251
|
+
// 0093 §1); ?. degrades to a no-op when the module is off.
|
|
252
|
+
seams.resolveOptional('onboarding')?.safeMarkStage(pool, req.builder.id, 'first_start', req.builder);
|
|
253
|
+
res.json({ claimable, active_claims: active });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// GET /tasks/newcomer-floor — supply gauge for the newcomer queue (task 614).
|
|
257
|
+
// Per-discipline counts of newcomer_friendly tasks in the two unclaimed live
|
|
258
|
+
// statuses: `ready` (claimable now — what the floor is measured against, per
|
|
259
|
+
// the canonical query in task 614) and `backlog` (held / pending review —
|
|
260
|
+
// proposals an Archon hasn't promoted yet). The AI floor replenisher
|
|
261
|
+
// (scripts/gds/newcomer-floor.js) reads this to decide which disciplines are
|
|
262
|
+
// under-supplied, counting `ready + backlog` so a re-run never piles fresh
|
|
263
|
+
// proposals on top of ones already awaiting review (idempotency). The hall can
|
|
264
|
+
// also render it as a live gauge.
|
|
265
|
+
//
|
|
266
|
+
// Global across open versions on purpose — a Xenos can claim any
|
|
267
|
+
// newcomer_friendly ready task regardless of version, so the floor is a
|
|
268
|
+
// project-wide promise, not a per-version one. Read-only, any-builder; the
|
|
269
|
+
// inline SQL follows the same self-contained-feature-locality call as the
|
|
270
|
+
// peer-vote queries above (kept out of the shared db.js).
|
|
271
|
+
// MUST stay registered before '/tasks/:id' or Express routes the literal
|
|
272
|
+
// 'newcomer-floor' into the :id param (same ordering constraint as /claimable).
|
|
273
|
+
router.get('/tasks/newcomer-floor', auth.requireBuilder, async (_req, res) => {
|
|
274
|
+
try {
|
|
275
|
+
const { rows } = await pool.query(
|
|
276
|
+
`SELECT COALESCE(discipline, 'unclassified') AS discipline, status, COUNT(*)::int AS n
|
|
277
|
+
FROM tasks
|
|
278
|
+
WHERE newcomer_friendly = true AND status IN ('ready', 'backlog')
|
|
279
|
+
GROUP BY COALESCE(discipline, 'unclassified'), status`
|
|
280
|
+
);
|
|
281
|
+
const disciplines = {};
|
|
282
|
+
for (const d of VALID_DISCIPLINES) disciplines[d] = { ready: 0, backlog: 0 };
|
|
283
|
+
for (const r of rows) {
|
|
284
|
+
// discipline is COALESCE'd to a non-null string above; a value outside
|
|
285
|
+
// the known set (shouldn't happen) still gets a bucket rather than throwing.
|
|
286
|
+
if (!disciplines[r.discipline]) disciplines[r.discipline] = { ready: 0, backlog: 0 };
|
|
287
|
+
disciplines[r.discipline][r.status] = r.n;
|
|
288
|
+
}
|
|
289
|
+
res.json({ disciplines });
|
|
290
|
+
} catch (err) {
|
|
291
|
+
console.error('[gds] GET /tasks/newcomer-floor', err);
|
|
292
|
+
res.fail('newcomer_floor_failed', 500);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// POST /tasks/newcomer-restock (task 1168) — bring every evergreen newcomer
|
|
297
|
+
// chore's standing pool up to its configured floor. This is the SEED + manual
|
|
298
|
+
// BACKSTOP for the on-claim restock (the live mechanism is the post-claim hook
|
|
299
|
+
// in routes/claims.js). Archon-gated like all task creation (ADR 0016). Must
|
|
300
|
+
// be registered BEFORE GET /tasks/:id so ':id' doesn't swallow the literal.
|
|
301
|
+
router.post('/tasks/newcomer-restock', auth.requireBuilder, auth.requireRank('archon'), async (_req, res) => {
|
|
302
|
+
try {
|
|
303
|
+
// The newcomer-chore restock carved into modules/onboarding/ (BV1.R81 / ADR
|
|
304
|
+
// 0093 §1): resolve the `onboarding` kernel port — never a direct require.
|
|
305
|
+
// Default-on (present in prod); a module-less instance has no chore shelf,
|
|
306
|
+
// so the backstop is a clean no-op (zero stamped).
|
|
307
|
+
const onboarding = seams.resolveOptional('onboarding');
|
|
308
|
+
const results = onboarding ? await onboarding.restockAll() : [];
|
|
309
|
+
const stamped = results.reduce((n, r) => n + (Array.isArray(r.stamped) ? r.stamped.length : 0), 0);
|
|
310
|
+
res.json({ ok: true, stamped, results });
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.error('[gds] POST /tasks/newcomer-restock', err);
|
|
313
|
+
res.fail('newcomer_restock_failed', { status: 500, message: 'internal error' });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// rank: any-builder — single-task read; preserved per CRITICAL §4 (supervisor hot path).
|
|
318
|
+
router.get('/tasks/:id', auth.requireBuilder, async (req, res) => {
|
|
319
|
+
const id = Number(req.params.id);
|
|
320
|
+
const task = await db.getTask(id);
|
|
321
|
+
if (!task) return res.fail('task_not_found', 404);
|
|
322
|
+
// #678: the `--regrade` CLI needs the most recent claim holder to gate "you
|
|
323
|
+
// last held this claim, or you're Archon". It's opt-in via ?include=last_claim
|
|
324
|
+
// so this CRITICAL §4 supervisor hot path doesn't pay for an extra query on
|
|
325
|
+
// every read (grader quality finding). Additive sibling key when requested.
|
|
326
|
+
const includes = String(req.query.include || '').split(',');
|
|
327
|
+
const wantLastClaim = includes.includes('last_claim');
|
|
328
|
+
const wantGrade = includes.includes('grade');
|
|
329
|
+
const [dependencies, dependents, lastClaim, grade] = await Promise.all([
|
|
330
|
+
db.getTaskDependencies(id),
|
|
331
|
+
db.getTaskDependents(id),
|
|
332
|
+
wantLastClaim ? db.getLastClaimHolder(id) : Promise.resolve(null),
|
|
333
|
+
wantGrade ? db.getTaskGrade(id) : Promise.resolve(null),
|
|
334
|
+
]);
|
|
335
|
+
const body = { task: { ...task, dependencies, dependents } };
|
|
336
|
+
if (wantLastClaim) body.last_claim = lastClaim;
|
|
337
|
+
if (wantGrade) body.grade = grade; // null when no grade exists yet
|
|
338
|
+
res.json(body);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// GET /tasks/:id/dependencies — same data, surfaced as its own resource.
|
|
342
|
+
// Returns { dependencies, dependents } — both arrays of task summaries.
|
|
343
|
+
// rank: any-builder — dep graph read; pure read, no mutation.
|
|
344
|
+
router.get('/tasks/:id/dependencies', auth.requireBuilder, async (req, res) => {
|
|
345
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
346
|
+
if (id === null) return;
|
|
347
|
+
const task = await db.getTask(id);
|
|
348
|
+
if (!task) return res.fail('task_not_found', 404);
|
|
349
|
+
const [dependencies, dependents] = await Promise.all([
|
|
350
|
+
db.getTaskDependencies(id),
|
|
351
|
+
db.getTaskDependents(id),
|
|
352
|
+
]);
|
|
353
|
+
res.json({ dependencies, dependents });
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// POST /tasks/:id/dependencies — body: { depends_on_task_id } OR
|
|
357
|
+
// { depends_on_task_ids: [N, ...] }
|
|
358
|
+
// Returns 409 with code='cycle' if accepting would create a cycle.
|
|
359
|
+
// rank: metic+archon — task-shaping; Metic may wire deps of a task it can author (ADR 0090).
|
|
360
|
+
router.post('/tasks/:id/dependencies', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
|
|
361
|
+
if (validateOrRespond(req, res, {
|
|
362
|
+
depends_on_task_ids: { type: 'array', maxItems: 200 },
|
|
363
|
+
// depends_on_task_id can be a number or a string-coerced number — leave
|
|
364
|
+
// the strict integer check to the existing Number.isFinite guard below
|
|
365
|
+
// (declared untyped so strict-mode accepts the singular form too).
|
|
366
|
+
depends_on_task_id: {},
|
|
367
|
+
})) return;
|
|
368
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
369
|
+
if (id === null) return;
|
|
370
|
+
const body = req.body || {};
|
|
371
|
+
let depIds = [];
|
|
372
|
+
if (Array.isArray(body.depends_on_task_ids)) {
|
|
373
|
+
depIds = body.depends_on_task_ids.map(Number).filter(Number.isFinite);
|
|
374
|
+
} else if (body.depends_on_task_id != null) {
|
|
375
|
+
depIds = [Number(body.depends_on_task_id)].filter(Number.isFinite);
|
|
376
|
+
}
|
|
377
|
+
if (depIds.length === 0) {
|
|
378
|
+
return res.fail('depends_on_task_id_required', 400);
|
|
379
|
+
}
|
|
380
|
+
const added = [];
|
|
381
|
+
for (const depId of depIds) {
|
|
382
|
+
try {
|
|
383
|
+
await db.addTaskDependency(id, depId);
|
|
384
|
+
added.push(depId);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
if (err && err.code === 'SELF_DEP') {
|
|
387
|
+
return res.fail('self_dep', 400, { task_id: id });
|
|
388
|
+
}
|
|
389
|
+
if (err && err.code === 'TASK_NOT_FOUND') {
|
|
390
|
+
return res.fail('task_not_found', 404);
|
|
391
|
+
}
|
|
392
|
+
if (err && err.code === 'CYCLE') {
|
|
393
|
+
return res.fail('cycle', 409, { path: err.path });
|
|
394
|
+
}
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const dependencies = await db.getTaskDependencies(id);
|
|
399
|
+
res.json({ ok: true, added, dependencies });
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// DELETE /tasks/:id/dependencies/:depId — removes the edge.
|
|
403
|
+
// rank: metic+archon — removes a dep edge; task-shaping, Metic+ (ADR 0090).
|
|
404
|
+
router.delete('/tasks/:id/dependencies/:depId', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
|
|
405
|
+
const id = Number(req.params.id);
|
|
406
|
+
const depId = Number(req.params.depId);
|
|
407
|
+
if (!Number.isFinite(id) || !Number.isFinite(depId)) {
|
|
408
|
+
return res.fail('bad_task_id', 400);
|
|
409
|
+
}
|
|
410
|
+
const removed = await db.removeTaskDependency(id, depId);
|
|
411
|
+
res.json({ ok: true, removed });
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// PUT /tasks/:id/dependencies — replace the full dep set.
|
|
415
|
+
// body: { depends_on_task_ids: [N, ...] }
|
|
416
|
+
// rank: metic+archon — replaces the full dep set; task-shaping, Metic+ (ADR 0090).
|
|
417
|
+
router.put('/tasks/:id/dependencies', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
|
|
418
|
+
if (validateOrRespond(req, res, {
|
|
419
|
+
depends_on_task_ids: { required: true, type: 'array', maxItems: 200 },
|
|
420
|
+
})) return;
|
|
421
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
422
|
+
if (id === null) return;
|
|
423
|
+
const body = req.body || {};
|
|
424
|
+
if (!Array.isArray(body.depends_on_task_ids)) {
|
|
425
|
+
return res.fail('depends_on_task_ids_required', 400);
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
const written = await db.setTaskDependencies(id, body.depends_on_task_ids);
|
|
429
|
+
const dependencies = await db.getTaskDependencies(id);
|
|
430
|
+
res.json({ ok: true, written, dependencies });
|
|
431
|
+
} catch (err) {
|
|
432
|
+
if (err && err.code === 'SELF_DEP') {
|
|
433
|
+
return res.fail('self_dep', 400);
|
|
434
|
+
}
|
|
435
|
+
if (err && err.code === 'CYCLE') {
|
|
436
|
+
return res.fail('cycle', 409, { path: err.path });
|
|
437
|
+
}
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// ---- task↔criterion links (task 438 / ADR 0025) — mirrors the dependency
|
|
443
|
+
// endpoints above. A criterion ref is a numeric done_when_criteria.id, a
|
|
444
|
+
// positional "Cn" token, or a criterion_id slug; the manage endpoints resolve
|
|
445
|
+
// it against the TASK's version so a cross-version link can't be formed.
|
|
446
|
+
|
|
447
|
+
// GET /tasks/:id/criteria — the criteria this task gates (with live "Cn" +
|
|
448
|
+
// source). Read-open like GET /tasks/:id/dependencies.
|
|
449
|
+
router.get('/tasks/:id/criteria', auth.requireBuilder, async (req, res) => {
|
|
450
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
451
|
+
if (id === null) return;
|
|
452
|
+
const criteria = await db.getTaskCriteria(id);
|
|
453
|
+
res.json({ criteria });
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// POST /tasks/:id/criteria — body: { criterion_ids: [ref, ...] } OR
|
|
457
|
+
// { criterion_id: ref }. Metic+.
|
|
458
|
+
// rank: metic+archon — links a task it can author to a criterion; mirrors POST deps (ADR 0090).
|
|
459
|
+
router.post('/tasks/:id/criteria', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
|
|
460
|
+
if (validateOrRespond(req, res, {
|
|
461
|
+
criterion_ids: { type: 'array', maxItems: 100 },
|
|
462
|
+
// criterion_id is the singular alt-form (a numeric id, "Cn" token, or slug)
|
|
463
|
+
// — untyped so strict-mode accepts it alongside the array form.
|
|
464
|
+
criterion_id: {},
|
|
465
|
+
})) return;
|
|
466
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
467
|
+
if (id === null) return;
|
|
468
|
+
const body = req.body || {};
|
|
469
|
+
let refs = [];
|
|
470
|
+
if (Array.isArray(body.criterion_ids)) refs = body.criterion_ids;
|
|
471
|
+
else if (body.criterion_id != null) refs = [body.criterion_id];
|
|
472
|
+
if (refs.length === 0) return res.fail('criterion_id_required', 400);
|
|
473
|
+
|
|
474
|
+
const task = await db.getTask(id);
|
|
475
|
+
if (!task) return res.fail('task_not_found', 404);
|
|
476
|
+
const { ids, unresolved } = await db.resolveCriterionIds(task.version_id, refs);
|
|
477
|
+
if (unresolved.length > 0) {
|
|
478
|
+
return res.fail('bad_criterion_ref', { status: 400, message: `These criterion refs don't match any criterion of version '${task.version_id}': ${unresolved.join(', ')}.`, details: { unresolved } });
|
|
479
|
+
}
|
|
480
|
+
const added = [];
|
|
481
|
+
for (const cid of ids) {
|
|
482
|
+
try {
|
|
483
|
+
await db.addTaskCriterion(id, cid, 'manual');
|
|
484
|
+
added.push(cid);
|
|
485
|
+
} catch (err) {
|
|
486
|
+
if (err && err.code === 'CRITERION_VERSION_MISMATCH') {
|
|
487
|
+
return res.fail('criterion_version_mismatch', 400);
|
|
488
|
+
}
|
|
489
|
+
if (err && err.code === 'CRITERION_NOT_FOUND') return res.fail('criterion_not_found', 404);
|
|
490
|
+
if (err && err.code === 'TASK_NOT_FOUND') return res.fail('task_not_found', 404);
|
|
491
|
+
throw err;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const criteria = await db.getTaskCriteria(id);
|
|
495
|
+
res.json({ ok: true, added, criteria });
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// DELETE /tasks/:id/criteria/:criterionId — removes one link (criterionId is
|
|
499
|
+
// the numeric done_when_criteria.id, mirroring DELETE deps/:depId).
|
|
500
|
+
// rank: metic+archon — removes a criterion↔task edge; task-shaping, Metic+ (ADR 0090).
|
|
501
|
+
router.delete('/tasks/:id/criteria/:criterionId', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
|
|
502
|
+
const id = Number(req.params.id);
|
|
503
|
+
const criterionId = Number(req.params.criterionId);
|
|
504
|
+
if (!Number.isFinite(id) || !Number.isFinite(criterionId)) {
|
|
505
|
+
return res.fail('bad_task_id', 400);
|
|
506
|
+
}
|
|
507
|
+
const removed = await db.removeTaskCriterion(id, criterionId);
|
|
508
|
+
res.json({ ok: true, removed });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Task create/promote/edit (plan/scope mutation) is Metic+ as of ADR 0090
|
|
512
|
+
// (owner decision 2026-06-26): a Metic who can run idea-triage may also seed +
|
|
513
|
+
// shape tasks. Was Archon-only (task #106 / ADR 0016; three-rank model #360 /
|
|
514
|
+
// ADR 0018). A Xenos/Thetes still cannot author — they file ideas. Setting rank
|
|
515
|
+
// and closing versions stay Archon. The every-route audit is task #231 (V3.R06).
|
|
516
|
+
// rank: metic+archon — create work (plan/scope mutation); ADR 0090 (was ADR 0016/0018 archon).
|
|
517
|
+
router.post('/tasks', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
|
|
518
|
+
if (validateOrRespond(req, res, {
|
|
519
|
+
version_id: { required: true, type: 'string', maxLength: 64 },
|
|
520
|
+
title: { required: true, type: 'string', maxLength: LIMITS.TITLE, minLength: 1 },
|
|
521
|
+
description: { type: 'string', maxLength: LIMITS.DESCRIPTION },
|
|
522
|
+
status: { type: 'string', maxLength: 32 },
|
|
523
|
+
touches: { type: 'array', maxItems: 500, itemsType: 'string' },
|
|
524
|
+
est_minutes: { type: 'integer', min: 0, max: 100000 },
|
|
525
|
+
est_cost_usd: { type: 'number', min: 0 },
|
|
526
|
+
manual_degree: { type: 'integer', min: TASK_FIELD_BOUNDS.manual_degree.min, max: TASK_FIELD_BOUNDS.manual_degree.max },
|
|
527
|
+
priority: { type: 'integer', min: TASK_FIELD_BOUNDS.priority.min, max: TASK_FIELD_BOUNDS.priority.max },
|
|
528
|
+
automation_tag: { type: 'string', maxLength: 64 },
|
|
529
|
+
credits_reward: { type: 'integer', min: 0, max: 1000000 },
|
|
530
|
+
source: { type: 'string', maxLength: LIMITS.SOURCE },
|
|
531
|
+
source_ref: { type: 'string', maxLength: LIMITS.SOURCE_REF },
|
|
532
|
+
value_summary: { type: 'string', maxLength: LIMITS.VALUE_SUMMARY },
|
|
533
|
+
kind: { type: 'string', maxLength: 64 },
|
|
534
|
+
discipline: { type: 'string', maxLength: 64 },
|
|
535
|
+
parallel_safe: {}, // accepts true/false/null — type checked below
|
|
536
|
+
newcomer_friendly: { type: 'boolean' }, // V3.R15 #239 — Xenos-claimable flag
|
|
537
|
+
security_sensitive: { type: 'boolean' }, // task 990 — Archon-only ship broadcast
|
|
538
|
+
requires_rank: { type: 'string', maxLength: 16 }, // task 1498 / ADR 0084 — per-task rank floor (auto-derived; explicit value can only raise it)
|
|
539
|
+
auto_detect_touches: { type: 'boolean' },
|
|
540
|
+
// task 438: link the new task to done-when criteria at creation time. Each
|
|
541
|
+
// element is a numeric done_when_criteria.id, a positional "Cn" token, or a
|
|
542
|
+
// criterion_id slug — resolved against this task's version below (ADR 0025).
|
|
543
|
+
criterion_ids: { type: 'array', maxItems: 100 },
|
|
544
|
+
// BV1.R53 (task 1422): module affinity label. Drives module-disjointness at
|
|
545
|
+
// claim time and diff⊆module drift detection at ship time.
|
|
546
|
+
module_key: { type: 'string', maxLength: 64 },
|
|
547
|
+
// Parent the new task under a specific goal at create (task 1763 added the
|
|
548
|
+
// PATCH-move vector; this is the create-time counterpart so a planning seed
|
|
549
|
+
// lands tasks under their goal, not the version's default catch-all).
|
|
550
|
+
// Validated to exist + belong to this version below.
|
|
551
|
+
goal_id: { type: 'integer', min: 1 },
|
|
552
|
+
})) return;
|
|
553
|
+
const body = req.body || {};
|
|
554
|
+
if (!body.version_id || !body.title) {
|
|
555
|
+
return res.fail('version_id_and_title_required', 400);
|
|
556
|
+
}
|
|
557
|
+
// task 975: a version is the ledger of one bounded chunk of work. Once it
|
|
558
|
+
// ships it is sealed history — its progress bar reads 100% and its scope
|
|
559
|
+
// is archived. Filing new work against it would reopen a closed book and
|
|
560
|
+
// (until the view fix above) silently drag the bar back below 100%. Reject
|
|
561
|
+
// creation against a missing or shipped version; the caller should target
|
|
562
|
+
// an open version or file the idea to idea_inbox for the next planning
|
|
563
|
+
// session. (POST /tasks is the only API create-vector — PATCH cannot
|
|
564
|
+
// re-bind version_id — so this is the complete guard.)
|
|
565
|
+
const targetVersion = await db.getVersion(body.version_id);
|
|
566
|
+
if (!targetVersion) {
|
|
567
|
+
return res.fail('version_not_found', { status: 404, message: `No version '${body.version_id}'. Create the task under an existing, open version.` });
|
|
568
|
+
}
|
|
569
|
+
// task 386 (idea #85): idempotency on (source, source_ref). A re-submitted
|
|
570
|
+
// create — a retried POST, a capture/promotion re-run — must resolve to the
|
|
571
|
+
// ORIGINAL task, not silently mint a duplicate (the #248-dup-of-#252 class of
|
|
572
|
+
// bug). Runs BEFORE the version-closed guard so re-POSTing the same ref after
|
|
573
|
+
// its task already shipped (version now closed) returns the existing row
|
|
574
|
+
// instead of a spurious 409. `source` defaults to 'manual' to match the
|
|
575
|
+
// createTask call below, so the lookup key is exactly what would be written.
|
|
576
|
+
const effectiveSource = body.source ?? 'manual';
|
|
577
|
+
if (body.source_ref) {
|
|
578
|
+
const existing = await db.findTaskBySourceRef({ source: effectiveSource, sourceRef: body.source_ref });
|
|
579
|
+
if (existing) {
|
|
580
|
+
const criteria = await db.getTaskCriteria(existing.id);
|
|
581
|
+
return res.status(200).json({
|
|
582
|
+
task: existing,
|
|
583
|
+
needs_migration: existing.needs_migration === true,
|
|
584
|
+
criteria,
|
|
585
|
+
idempotent: true,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (targetVersion.status === 'shipped') {
|
|
590
|
+
return res.fail('version_closed', { status: 409, message: `Version '${body.version_id}' has shipped — it is closed history and cannot take new tasks. ` +
|
|
591
|
+
`Target an open version, or capture this in idea_inbox for the next planning session.` });
|
|
592
|
+
}
|
|
593
|
+
// task 438: resolve any criterion_ids BEFORE creating the task, so a bad ref
|
|
594
|
+
// is a clean 400 with nothing created. Scoped to this task's version, so "C8"
|
|
595
|
+
// is unambiguous and a numeric id from another version doesn't resolve.
|
|
596
|
+
let criterionIdsResolved = [];
|
|
597
|
+
if (Array.isArray(body.criterion_ids) && body.criterion_ids.length > 0) {
|
|
598
|
+
const { ids, unresolved } = await db.resolveCriterionIds(body.version_id, body.criterion_ids);
|
|
599
|
+
if (unresolved.length > 0) {
|
|
600
|
+
return res.fail('bad_criterion_ref', { status: 400, message: `These criterion refs don't match any criterion of version '${body.version_id}': ` +
|
|
601
|
+
`${unresolved.join(', ')}. Use a numeric done_when_criteria.id, a "Cn" position, or a criterion_id slug.`, details: { unresolved } });
|
|
602
|
+
}
|
|
603
|
+
criterionIdsResolved = ids;
|
|
604
|
+
}
|
|
605
|
+
// Resolve kind: caller wins if they specified a valid non-unclassified
|
|
606
|
+
// value; otherwise run the heuristic classifier on title+description.
|
|
607
|
+
// Confidence < 0.5 falls through to 'unclassified' for triage.
|
|
608
|
+
let kind = body.kind;
|
|
609
|
+
if (kind && !VALID_KINDS.has(kind)) {
|
|
610
|
+
return badKind(res);
|
|
611
|
+
}
|
|
612
|
+
if (!kind || kind === 'unclassified') {
|
|
613
|
+
const result = classifyTaskKind(body.title, body.description || '');
|
|
614
|
+
kind = result.confidence >= 0.5 ? result.kind : 'unclassified';
|
|
615
|
+
}
|
|
616
|
+
const discipline = body.discipline ?? 'unclassified';
|
|
617
|
+
if (!VALID_DISCIPLINES.has(discipline)) {
|
|
618
|
+
return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
|
|
619
|
+
}
|
|
620
|
+
// task 1498 / ADR 0084: an explicit requires_rank must be a live rank. The
|
|
621
|
+
// floor auto-derives from touches + security_sensitive; createTask clamps
|
|
622
|
+
// any explicit value UP to that floor (never below), so this only rejects a
|
|
623
|
+
// malformed enum.
|
|
624
|
+
if (body.requires_rank != null) {
|
|
625
|
+
const LIVE_RANKS = new Set(db.LIVE_RANK_LADDER);
|
|
626
|
+
if (!LIVE_RANKS.has(body.requires_rank)) {
|
|
627
|
+
return res.fail('bad_requires_rank', { status: 400, message: `requires_rank must be one of ${db.LIVE_RANK_LADDER.join(' | ')}. The floor auto-derives from touches + security_sensitive; an explicit value can only raise it.`, details: { valid: db.LIVE_RANK_LADDER } });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// BV1.R53 (task 1422): validate module_key against MODULE_SCOPE_MAP.
|
|
631
|
+
if (body.module_key != null && !isModuleKey(body.module_key)) {
|
|
632
|
+
return res.fail('bad_module_key', { status: 400, message: `module_key must be one of the known module keys, or omitted. Valid keys: ${moduleKeys().join(', ')}.`, details: { valid: moduleKeys() } });
|
|
633
|
+
}
|
|
634
|
+
// V3.R48 (#267) → migration 081 (task 876): if the title/description reads
|
|
635
|
+
// like a schema change, flag tasks.needs_migration so the claim-time
|
|
636
|
+
// allocator reserves a migration number. This used to inject the literal
|
|
637
|
+
// 'migrations/' into touches[]; it now sets a dedicated boolean and leaves
|
|
638
|
+
// touches[] alone. Opt out with { auto_detect_touches: false }.
|
|
639
|
+
const touches = Array.isArray(body.touches) ? [...body.touches] : [];
|
|
640
|
+
const autoDetect = body.auto_detect_touches !== false;
|
|
641
|
+
const needsMigration = autoDetect && detectsMigration(body.title, body.description);
|
|
642
|
+
// Validate an explicit goal_id: it must exist AND belong to THIS version (a task
|
|
643
|
+
// can't be parented under another version's goal). Absent → the default-goal
|
|
644
|
+
// trigger files it under the version's catch-all goal, exactly as before.
|
|
645
|
+
if (body.goal_id != null) {
|
|
646
|
+
const goal = await db.getGoal(body.goal_id);
|
|
647
|
+
if (!goal || String(goal.version_id) !== String(body.version_id)) {
|
|
648
|
+
return res.fail('bad_goal_id', { status: 400, message: `goal_id ${body.goal_id} does not exist on version '${body.version_id}'.` });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
const task = await db.createTask({
|
|
653
|
+
versionId: body.version_id,
|
|
654
|
+
title: body.title,
|
|
655
|
+
description: body.description || '',
|
|
656
|
+
status: body.status || 'backlog',
|
|
657
|
+
touches,
|
|
658
|
+
estMinutes: body.est_minutes ?? null,
|
|
659
|
+
estCostUsd: body.est_cost_usd ?? null,
|
|
660
|
+
manualDegree: body.manual_degree ?? null,
|
|
661
|
+
priority: body.priority ?? null,
|
|
662
|
+
automationTag: body.automation_tag ?? null,
|
|
663
|
+
creditsReward: body.credits_reward ?? 0,
|
|
664
|
+
source: effectiveSource,
|
|
665
|
+
sourceRef: body.source_ref ?? null,
|
|
666
|
+
valueSummary: body.value_summary ?? null,
|
|
667
|
+
kind,
|
|
668
|
+
discipline,
|
|
669
|
+
parallelSafe: typeof body.parallel_safe === 'boolean' ? body.parallel_safe : null,
|
|
670
|
+
newcomerFriendly: body.newcomer_friendly === true,
|
|
671
|
+
securitySensitive: body.security_sensitive === true,
|
|
672
|
+
needsMigration,
|
|
673
|
+
requiresRank: body.requires_rank ?? null,
|
|
674
|
+
moduleKey: body.module_key ?? null,
|
|
675
|
+
goalId: body.goal_id ?? null,
|
|
676
|
+
});
|
|
677
|
+
// task 438: link resolved criteria (source='planning' — task-create is the
|
|
678
|
+
// planning vector). The ids were version-checked above, so this is a single
|
|
679
|
+
// idempotent batch insert; surface the linked criteria in the response so the
|
|
680
|
+
// caller (and planning-session) can confirm the task is attributed.
|
|
681
|
+
let criteria = [];
|
|
682
|
+
if (criterionIdsResolved.length > 0) {
|
|
683
|
+
await db.linkTaskCriteria(task.id, criterionIdsResolved, 'planning');
|
|
684
|
+
criteria = await db.getTaskCriteria(task.id);
|
|
685
|
+
}
|
|
686
|
+
// task 1677 / ADR 0096: surface the assigned credits_reward so the creator sees
|
|
687
|
+
// the drachmae value the task was born with — auto-assigned at creation when the
|
|
688
|
+
// caller supplied no positive value (0/absent flows into createTask as "unset").
|
|
689
|
+
// task 1957: credits_reward is an ESTIMATE, not a guaranteed payout. The hall
|
|
690
|
+
// renders it as "<Currency> Est." until the task settles (confirmed/shipped),
|
|
691
|
+
// then switches to the real ledger figure (credits_awarded from the task read).
|
|
692
|
+
res.status(201).json({
|
|
693
|
+
task,
|
|
694
|
+
credits_reward: task.credits_reward,
|
|
695
|
+
needs_migration: needsMigration,
|
|
696
|
+
criteria,
|
|
697
|
+
});
|
|
698
|
+
} catch (err) {
|
|
699
|
+
console.error('[gds] POST /tasks', err);
|
|
700
|
+
res.fail('create_failed', { status: 500, message: 'internal error' });
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
// PATCH /tasks/:id — supports body.parent_task_id (V2 C2), body.kind (V2 B1),
|
|
705
|
+
// body.discipline (mig 014), body.parallel_safe
|
|
706
|
+
// (mig 028), and body.newcomer_friendly (#360 / ADR 0018 + V3.R15 #239 —
|
|
707
|
+
// body.touches was removed in touches[] cleanup [4/8] (task 879) along with
|
|
708
|
+
// the drift scanner —
|
|
709
|
+
// open a task to the Xenos sandbox; alias body.xenos_claimable accepted for
|
|
710
|
+
// pre-rename callers). Pass parent_task_id: null to detach; parallel_safe
|
|
711
|
+
// accepts true|false|null; newcomer_friendly is a boolean. Validation lives
|
|
712
|
+
// in hierarchy.js for parent; the rest are validated inline.
|
|
713
|
+
// rank: metic+archon — edit work (incl. newcomer_friendly flag); ADR 0090 (was ADR 0016 archon).
|
|
714
|
+
router.patch('/tasks/:id', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
|
|
715
|
+
if (validateOrRespond(req, res, {
|
|
716
|
+
kind: { type: 'string', maxLength: 64 },
|
|
717
|
+
discipline: { type: 'string', maxLength: 64 },
|
|
718
|
+
requires_rank: { type: 'string', maxLength: 16 }, // task 1498 / ADR 0084 — override the per-task rank floor (upward only)
|
|
719
|
+
module_key: { type: 'string', maxLength: 64 }, // BV1.R53 (task 1422) — module affinity label
|
|
720
|
+
goal_id: { type: 'integer', min: 1 }, // task 1763 — (re)assign the task's goal (must be in the task's version)
|
|
721
|
+
// touches[] cleanup [4/8] (task 879): touches is no longer PATCH-able —
|
|
722
|
+
// the drift scanner + mid-claim expansion were removed. The rest are
|
|
723
|
+
// checked inline below but must be DECLARED so strict-mode (R11/#1998)
|
|
724
|
+
// accepts them instead of rejecting them as unknown fields.
|
|
725
|
+
parent_task_id: {}, // number id, or null to detach (hasOwnProperty-gated below)
|
|
726
|
+
parallel_safe: {}, // true/false/null — checked inline
|
|
727
|
+
newcomer_friendly: { type: 'boolean' }, // V3.R15 — Xenos-claimable flag
|
|
728
|
+
xenos_claimable: { type: 'boolean' }, // pre-rename alias for newcomer_friendly
|
|
729
|
+
security_sensitive: { type: 'boolean' }, // task 990 — Archon-only ship broadcast
|
|
730
|
+
automation_tag: { type: 'string', maxLength: 64 },
|
|
731
|
+
})) return;
|
|
732
|
+
const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
|
|
733
|
+
if (id === null) return;
|
|
734
|
+
const body = req.body || {};
|
|
735
|
+
const hasParent = Object.prototype.hasOwnProperty.call(body, 'parent_task_id');
|
|
736
|
+
const hasKind = Object.prototype.hasOwnProperty.call(body, 'kind');
|
|
737
|
+
const hasDiscipline = Object.prototype.hasOwnProperty.call(body, 'discipline');
|
|
738
|
+
// V3.R15 #239: newcomer_friendly is the canonical name; xenos_claimable is
|
|
739
|
+
// the pre-rename alias and still accepted. Either presence triggers the
|
|
740
|
+
// same update path.
|
|
741
|
+
const hasNewcomerFriendly = Object.prototype.hasOwnProperty.call(body, 'newcomer_friendly');
|
|
742
|
+
const hasXenosClaimable = Object.prototype.hasOwnProperty.call(body, 'xenos_claimable');
|
|
743
|
+
const hasNewcomerOrXenos = hasNewcomerFriendly || hasXenosClaimable;
|
|
744
|
+
const hasParallelSafe = Object.prototype.hasOwnProperty.call(body, 'parallel_safe');
|
|
745
|
+
const hasSecuritySensitive = Object.prototype.hasOwnProperty.call(body, 'security_sensitive');
|
|
746
|
+
const hasAutomationTag = Object.prototype.hasOwnProperty.call(body, 'automation_tag');
|
|
747
|
+
const hasRequiresRank = Object.prototype.hasOwnProperty.call(body, 'requires_rank');
|
|
748
|
+
const hasModuleKey = Object.prototype.hasOwnProperty.call(body, 'module_key');
|
|
749
|
+
const hasGoalId = Object.prototype.hasOwnProperty.call(body, 'goal_id');
|
|
750
|
+
// #514: status is a lifecycle field, not a PATCH-able column — it moves only
|
|
751
|
+
// through the dedicated transition routes. The #506 session tried
|
|
752
|
+
// `PATCH status=ready`, got the generic no_supported_fields error, and had
|
|
753
|
+
// to go spelunking for the promote route. Detect status explicitly and point
|
|
754
|
+
// the caller straight at the right endpoint.
|
|
755
|
+
const STATUS_HINT =
|
|
756
|
+
'status is not editable via PATCH; use POST /tasks/:id/promote (backlog->ready), ' +
|
|
757
|
+
'/tasks/:id/confirm, /tasks/:id/ship, or /claims/:id/resolve to move a task through its lifecycle.';
|
|
758
|
+
if (Object.prototype.hasOwnProperty.call(body, 'status')) {
|
|
759
|
+
return res.fail('status_not_patchable', { status: 400, message: STATUS_HINT });
|
|
760
|
+
}
|
|
761
|
+
if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasRequiresRank && !hasModuleKey && !hasGoalId) {
|
|
762
|
+
return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, parallel_safe, security_sensitive, automation_tag, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
|
|
763
|
+
}
|
|
764
|
+
let updated = null;
|
|
765
|
+
if (hasParallelSafe) {
|
|
766
|
+
if (body.parallel_safe !== null && typeof body.parallel_safe !== 'boolean') {
|
|
767
|
+
return res.fail('bad_parallel_safe', { status: 400, message: 'parallel_safe must be true, false, or null' });
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
updated = await db.updateTaskParallelSafe(id, body.parallel_safe);
|
|
771
|
+
if (!updated) return res.fail('task_not_found', 404);
|
|
772
|
+
} catch (err) {
|
|
773
|
+
console.error('[gds] PATCH /tasks/:id parallel_safe', err);
|
|
774
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (hasSecuritySensitive) {
|
|
778
|
+
// task 990: explicit Archon-only-broadcast flag. Boolean only (no null) —
|
|
779
|
+
// it gates whether ship.js routes the broadcast to the Archon-only
|
|
780
|
+
// #security-ship-feed channel instead of the public feeds.
|
|
781
|
+
// ADR 0090: PATCH /tasks/:id opened to Metic+, but security_sensitive is a
|
|
782
|
+
// SECURITY control (broadcast suppression + it feeds the auto-derived rank
|
|
783
|
+
// floor, ADR 0084), not a planning field — so it stays Archon-only via this
|
|
784
|
+
// in-handler gate. A Metic editing any other field is unaffected; only a
|
|
785
|
+
// requires_rank-style escalation surface is withheld. (Setting it at create
|
|
786
|
+
// time is safe — a brand-new task has no existing sensitivity to clear.)
|
|
787
|
+
if (!auth.rankMeetsThreshold(req.builder.rank, ['archon'])) {
|
|
788
|
+
return res.fail('rank_forbidden', 403, { required: ['archon'], actual: req.builder.rank, reason: 'security_sensitive is an Archon-only security control (ADR 0090); a Metic may edit every other task field.' });
|
|
789
|
+
}
|
|
790
|
+
if (typeof body.security_sensitive !== 'boolean') {
|
|
791
|
+
return res.fail('bad_security_sensitive', { status: 400, message: 'security_sensitive must be a boolean' });
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
updated = await db.updateTaskSecuritySensitive(id, body.security_sensitive);
|
|
795
|
+
if (!updated) return res.fail('task_not_found', 404);
|
|
796
|
+
} catch (err) {
|
|
797
|
+
console.error('[gds] PATCH /tasks/:id security_sensitive', err);
|
|
798
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (hasAutomationTag) {
|
|
802
|
+
const tag = body.automation_tag;
|
|
803
|
+
const VALID_AUTOMATION_TAGS = new Set(['overnight-safe', 'live-only', 'needs-input']);
|
|
804
|
+
if (tag !== null && (typeof tag !== 'string' || !VALID_AUTOMATION_TAGS.has(tag))) {
|
|
805
|
+
return res.fail('bad_automation_tag', { status: 400, message: 'automation_tag must be one of overnight-safe | live-only | needs-input, or null to clear', details: { valid: Array.from(VALID_AUTOMATION_TAGS) } });
|
|
806
|
+
}
|
|
807
|
+
try {
|
|
808
|
+
updated = await db.updateTaskAutomationTag(id, tag);
|
|
809
|
+
if (!updated) return res.fail('task_not_found', 404);
|
|
810
|
+
} catch (err) {
|
|
811
|
+
console.error('[gds] PATCH /tasks/:id automation_tag', err);
|
|
812
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (hasModuleKey) {
|
|
816
|
+
// BV1.R53 (task 1422): assign or clear the module affinity label. null clears.
|
|
817
|
+
const key = body.module_key;
|
|
818
|
+
if (key !== null && !isModuleKey(key)) {
|
|
819
|
+
return res.fail('bad_module_key', { status: 400, message: `module_key must be one of the known module keys, or null to clear. Valid keys: ${moduleKeys().join(', ')}.`, details: { valid: moduleKeys() } });
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
updated = await db.updateTaskModuleKey(id, key ?? null);
|
|
823
|
+
if (!updated) return res.fail('task_not_found', 404);
|
|
824
|
+
} catch (err) {
|
|
825
|
+
console.error('[gds] PATCH /tasks/:id module_key', err);
|
|
826
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (hasGoalId) {
|
|
830
|
+
// task 1763: (re)assign the task's goal — the documented "Archon/Metic
|
|
831
|
+
// mechanism" to set goal_id (this route is metic+archon). A task belongs to
|
|
832
|
+
// exactly one goal WITHIN its own version: both the per-version general goals
|
|
833
|
+
// and goal-authored tasks hold this, and the /status rollup walks
|
|
834
|
+
// version→goal→task, so a cross-version link would drop the task out of its
|
|
835
|
+
// version's rollup. Refuse that (409). goal_id is never cleared to null here
|
|
836
|
+
// (that reintroduces an orphan) — a positive integer is required.
|
|
837
|
+
const goalId = body.goal_id;
|
|
838
|
+
if (!Number.isInteger(goalId) || goalId <= 0) {
|
|
839
|
+
return res.fail('bad_goal_id', { status: 400, message: 'goal_id must be a positive integer — a task belongs to exactly one goal.' });
|
|
840
|
+
}
|
|
841
|
+
try {
|
|
842
|
+
const task = await db.getTask(id);
|
|
843
|
+
if (!task) return res.fail('task_not_found', 404);
|
|
844
|
+
const goal = await db.getGoal(goalId);
|
|
845
|
+
if (!goal) return res.fail('goal_not_found', { status: 404, message: `No goal #${goalId}.` });
|
|
846
|
+
if (String(goal.version_id) !== String(task.version_id)) {
|
|
847
|
+
return res.fail('goal_version_mismatch', { status: 409, message: `Goal #${goalId} is in version '${goal.version_id}' but task #${id} is in '${task.version_id}'. A task can only join a goal in its own version.` });
|
|
848
|
+
}
|
|
849
|
+
updated = await db.updateTaskGoal(id, goalId);
|
|
850
|
+
if (!updated) return res.fail('task_not_found', 404);
|
|
851
|
+
} catch (err) {
|
|
852
|
+
console.error('[gds] PATCH /tasks/:id goal_id', err);
|
|
853
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (hasRequiresRank) {
|
|
857
|
+
// task 1498 / ADR 0084: the Archon override of the per-task rank floor.
|
|
858
|
+
// Validate the enum here; db.updateTaskRequiresRank clamps the value UP to
|
|
859
|
+
// the auto-derived floor (touches + security_sensitive), so a PATCH can
|
|
860
|
+
// only RAISE the gate — never open a sensitive task to a lower rank. Runs
|
|
861
|
+
// AFTER the security_sensitive block above, so a same-request flag flip is
|
|
862
|
+
// already reflected in the derived floor.
|
|
863
|
+
const LIVE_RANKS = new Set(db.LIVE_RANK_LADDER);
|
|
864
|
+
if (typeof body.requires_rank !== 'string' || !LIVE_RANKS.has(body.requires_rank)) {
|
|
865
|
+
return res.fail('bad_requires_rank', { status: 400, message: `requires_rank must be one of ${db.LIVE_RANK_LADDER.join(' | ')}. An override can only raise the floor; it is clamped up to the auto-derived minimum.`, details: { valid: db.LIVE_RANK_LADDER } });
|
|
866
|
+
}
|
|
867
|
+
try {
|
|
868
|
+
updated = await db.updateTaskRequiresRank(id, body.requires_rank);
|
|
869
|
+
if (!updated) return res.fail('task_not_found', 404);
|
|
870
|
+
} catch (err) {
|
|
871
|
+
console.error('[gds] PATCH /tasks/:id requires_rank', err);
|
|
872
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
if (hasKind) {
|
|
876
|
+
if (!VALID_KINDS.has(body.kind)) {
|
|
877
|
+
return badKind(res);
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
updated = await db.updateTaskKind(id, body.kind);
|
|
881
|
+
if (!updated) return res.fail('task_not_found', 404);
|
|
882
|
+
} catch (err) {
|
|
883
|
+
console.error('[gds] PATCH /tasks/:id kind', err);
|
|
884
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (hasDiscipline) {
|
|
888
|
+
if (!VALID_DISCIPLINES.has(body.discipline)) {
|
|
889
|
+
return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
|
|
890
|
+
}
|
|
891
|
+
try {
|
|
892
|
+
updated = await db.updateTaskDiscipline(id, body.discipline);
|
|
893
|
+
if (!updated) return res.fail('task_not_found', 404);
|
|
894
|
+
} catch (err) {
|
|
895
|
+
console.error('[gds] PATCH /tasks/:id discipline', err);
|
|
896
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (hasNewcomerOrXenos) {
|
|
900
|
+
// V3.R15 #239: prefer newcomer_friendly (canonical); fall back to
|
|
901
|
+
// xenos_claimable (pre-rename alias). Both map to the same DB column.
|
|
902
|
+
const incoming = hasNewcomerFriendly ? body.newcomer_friendly : body.xenos_claimable;
|
|
903
|
+
if (typeof incoming !== 'boolean') {
|
|
904
|
+
const field = hasNewcomerFriendly ? 'newcomer_friendly' : 'xenos_claimable';
|
|
905
|
+
return res.fail(`bad_${field}`, { status: 400, message: `${field} must be a boolean` });
|
|
906
|
+
}
|
|
907
|
+
try {
|
|
908
|
+
updated = await db.updateTaskNewcomerFriendly(id, incoming);
|
|
909
|
+
if (!updated) return res.fail('task_not_found', 404);
|
|
910
|
+
} catch (err) {
|
|
911
|
+
console.error('[gds] PATCH /tasks/:id newcomer_friendly', err);
|
|
912
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (!hasParent) return res.json({ task: updated });
|
|
916
|
+
try {
|
|
917
|
+
const task = await hierarchy.attachParent(id, body.parent_task_id);
|
|
918
|
+
res.json({ task });
|
|
919
|
+
} catch (err) {
|
|
920
|
+
if (err && err.code) {
|
|
921
|
+
const status =
|
|
922
|
+
err.code === 'TASK_NOT_FOUND' ? 404 :
|
|
923
|
+
err.code === 'PARENT_NOT_FOUND' ? 404 :
|
|
924
|
+
err.code === 'SELF_PARENT' ? 409 :
|
|
925
|
+
err.code === 'VERSION_MISMATCH' ? 409 :
|
|
926
|
+
err.code === 'PARENT_HAS_PARENT' ? 409 :
|
|
927
|
+
err.code === 'TARGET_HAS_CHILDREN' ? 409 :
|
|
928
|
+
500;
|
|
929
|
+
return res.fail(err.code, status, { ...err });
|
|
930
|
+
}
|
|
931
|
+
console.error('[gds] PATCH /tasks/:id', err);
|
|
932
|
+
res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
// rank: metic+archon — backlog→ready promotion; scope mutation; ADR 0090 (was archon).
|
|
937
|
+
router.post('/tasks/:id/promote', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
|
|
938
|
+
const id = Number(req.params.id);
|
|
939
|
+
const task = await db.getTask(id);
|
|
940
|
+
if (!task) return res.fail('task_not_found', 404);
|
|
941
|
+
if (!['backlog', 'blocked'].includes(task.status)) {
|
|
942
|
+
return res.fail('cannot_promote', 409, { current_status: task.status });
|
|
943
|
+
}
|
|
944
|
+
// task 1670 / ADR 0096 + task 1673 amendment (2026-06-28): a task must never
|
|
945
|
+
// become workable paying 0 drachmae — but the gate AUTO-ASSIGNS rather than
|
|
946
|
+
// BLOCKS. If credits_reward is 0/NULL, assign the capped suggestion (from
|
|
947
|
+
// kind/est, cap 60) and PROCEED to promote. The promote is never refused for
|
|
948
|
+
// a missing reward. This reverses ADR 0023's strict author-set stance for the
|
|
949
|
+
// gate-time value only (capped + audited); explicit author-set values via
|
|
950
|
+
// PATCH /tasks/:id and the ungameable cost-plus session reward (ADR 0054)
|
|
951
|
+
// remain the primary drivers. AUDIT: setCreditsReward logs + the visible
|
|
952
|
+
// credits_reward change.
|
|
953
|
+
let assignedReward = null;
|
|
954
|
+
const rewardAssign = db.rewardGateAssignment(task);
|
|
955
|
+
if (rewardAssign) {
|
|
956
|
+
const updated = await db.setCreditsReward(id, rewardAssign.assign);
|
|
957
|
+
assignedReward = updated ? updated.credits_reward : rewardAssign.assign;
|
|
958
|
+
}
|
|
959
|
+
await db.updateTaskStatus(id, 'ready');
|
|
960
|
+
res.json({ ok: true, ...(assignedReward !== null ? { assigned_credits_reward: assignedReward } : {}) });
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
// POST /tasks/:id/demote — the strict inverse of /promote: flip a task
|
|
964
|
+
// ready→backlog and nothing else. Use case (task 1223): a dependency-gated task
|
|
965
|
+
// that was wrongly promoted to ready needs to go back to backlog without
|
|
966
|
+
// hand-SQL on prod. Mirrors /promote's auth (Metic+ — scope mutation, ADR 0090).
|
|
967
|
+
//
|
|
968
|
+
// Strictly ready→backlog: refuse any other current status (blocked, active, or
|
|
969
|
+
// any later lifecycle state). A claimed task is `active`, so this gate also
|
|
970
|
+
// refuses demoting work that's already in flight — demote is not a way to yank a
|
|
971
|
+
// task out from under its claim holder. No reward side-effects (the reward gate
|
|
972
|
+
// is a promote-only concern; backlog is the pre-workable state).
|
|
973
|
+
// rank: metic+archon — ready→backlog demotion; scope mutation; mirrors /promote (ADR 0090).
|
|
974
|
+
router.post('/tasks/:id/demote', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
|
|
975
|
+
const id = Number(req.params.id);
|
|
976
|
+
const task = await db.getTask(id);
|
|
977
|
+
if (!task) return res.fail('task_not_found', 404);
|
|
978
|
+
if (task.status !== 'ready') {
|
|
979
|
+
return res.fail('cannot_demote', 409, { current_status: task.status });
|
|
980
|
+
}
|
|
981
|
+
await db.updateTaskStatus(id, 'backlog');
|
|
982
|
+
res.json({ ok: true });
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
// Phase 5 lifecycle transitions: completed → confirmed → shipped.
|
|
986
|
+
// /resolve handles active → completed (and the auto-completed-to-confirmed
|
|
987
|
+
// promotion when verification passes). These endpoints handle the manual
|
|
988
|
+
// overrides + the final merge step.
|
|
989
|
+
|
|
990
|
+
// confirmed → shipped. Called by /merge-mode (or the auto-merge chain in
|
|
991
|
+
// /builder-ship) once the branch is on main + deployed.
|
|
992
|
+
// rank: any-builder — confirmed→shipped on builder's own work; supervisor hot path
|
|
993
|
+
// (preserved per CRITICAL §4 — no requireRank).
|
|
994
|
+
router.post('/tasks/:id/ship', auth.requireBuilder, async (req, res) => {
|
|
995
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
996
|
+
if (id === null) return;
|
|
997
|
+
// SECURITY (#871): confirmed→shipped is an any-builder supervisor hot path so
|
|
998
|
+
// /merge-mode can sweep the queue — but a Xenos/Thetes should only flip their
|
|
999
|
+
// OWN confirmed task. allowSupervisor lets Metic+ (who run /merge-mode) ship
|
|
1000
|
+
// across builders; the claim holder ships their own; everyone else is 403'd.
|
|
1001
|
+
if (!(await gateTaskOwnership(req, res, id, { allowSupervisor: true }))) return;
|
|
1002
|
+
try {
|
|
1003
|
+
const task = await db.shipTask({ taskId: id, builderId: req.builder.id });
|
|
1004
|
+
// task 1749: emit task.shipped on THIS entry point too, not just the
|
|
1005
|
+
// publish-status reconcile-on-read path (line ~1545) + the reconciler
|
|
1006
|
+
// sweep. A direct ship here (e.g. /merge-mode sweeping the confirmed
|
|
1007
|
+
// queue) otherwise lands with no Discord broadcast. shipTask is the
|
|
1008
|
+
// confirmed→shipped flip and throws TASK_NOT_CONFIRMED on a repeat, so
|
|
1009
|
+
// whichever entry point wins the transition is the only one that reaches
|
|
1010
|
+
// this line — the emit fires exactly ONCE per ship across all three
|
|
1011
|
+
// paths. The server is thus the SINGLE ship broadcaster (the box/laptop
|
|
1012
|
+
// local post in scripts/gds/ship.js was removed as a duplicate, task
|
|
1013
|
+
// 1749). Best-effort: emitAsync isolates listener errors so a broadcast
|
|
1014
|
+
// hiccup can never turn a successful ship into a 500.
|
|
1015
|
+
try { await seams.emitAsync('task.shipped', task); } catch (_) { /* non-blocking */ }
|
|
1016
|
+
res.json({ ok: true, task });
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
if (err.code === 'TASK_NOT_CONFIRMED') {
|
|
1019
|
+
return res.fail('task_not_confirmed', 409);
|
|
1020
|
+
}
|
|
1021
|
+
console.error('[gds] POST /tasks/:id/ship', err);
|
|
1022
|
+
res.fail('ship_failed', { status: 500, message: 'internal error' });
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
// completed → confirmed. Manual override for cases where automated
|
|
1027
|
+
// verification at /resolve time was inconclusive but a human has since
|
|
1028
|
+
// verified. Awards credits if not already credited (idempotent).
|
|
1029
|
+
//
|
|
1030
|
+
// Archon-only (#231): this bypasses the subagent quality grader, so it must
|
|
1031
|
+
// not be self-serviceable by a builder pushing their own work past a failed
|
|
1032
|
+
// grade. /ship and /grade below stay open — they're part of any builder's
|
|
1033
|
+
// own ship flow (the grade is computed by an objective subagent).
|
|
1034
|
+
//
|
|
1035
|
+
// #853 (ADR 0041): a temporary, default-OFF env kill-switch
|
|
1036
|
+
// GRADER_BYPASS_ENABLED relaxes this gate to any-builder during a grader
|
|
1037
|
+
// OUTAGE (e.g. a provider 429), so every builder can land work without the
|
|
1038
|
+
// subagent grader. When the env is unset/!=='1' the archon-only gate from
|
|
1039
|
+
// #231 stays in force, preserving the ADR 0016 trust boundary by default.
|
|
1040
|
+
// The relaxation is logged so the audit trail shows every bypass confirm.
|
|
1041
|
+
// rank: archon — grader-bypass override (relaxed to any-builder only while
|
|
1042
|
+
// GRADER_BYPASS_ENABLED==='1'; archon-only otherwise).
|
|
1043
|
+
const confirmRankGate = (req, res, next) => {
|
|
1044
|
+
if (graderBypassEnabled()) return next();
|
|
1045
|
+
return auth.requireRank('archon')(req, res, next);
|
|
1046
|
+
};
|
|
1047
|
+
router.post('/tasks/:id/confirm', auth.requireBuilder, confirmRankGate, async (req, res) => {
|
|
1048
|
+
if (validateOrRespond(req, res, {
|
|
1049
|
+
// { verified: true } attestation for verify-kind tasks (checked below);
|
|
1050
|
+
// absent/any-shape otherwise. Untyped so the supervisor's `{}` and the
|
|
1051
|
+
// `--verified` boolean both pass; strict rejects a typo'd extra field.
|
|
1052
|
+
verified: {},
|
|
1053
|
+
})) return;
|
|
1054
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
1055
|
+
if (id === null) return;
|
|
1056
|
+
// SECURITY (#871): the grader-bypass relaxation lets ANY builder reach this
|
|
1057
|
+
// route during the outage. Confirm is meant to land a builder's OWN work past
|
|
1058
|
+
// the (absent) grader — NOT to let anyone confirm anyone's task. Gate to the
|
|
1059
|
+
// claim holder (or Archon). In the normal (non-bypass) path the route is
|
|
1060
|
+
// already archon-only, and an Archon always passes this check.
|
|
1061
|
+
if (!(await gateTaskOwnership(req, res, id))) return;
|
|
1062
|
+
// task 1053 (F): a verification task must not be confirmed before the verify
|
|
1063
|
+
// actually ran. A passing code-grade (or the skip-grade bypass) says nothing
|
|
1064
|
+
// about whether the hardware-verify was performed — that gap let #1027 sit at
|
|
1065
|
+
// 'confirmed' unverified. Require an explicit { verified: true } attestation.
|
|
1066
|
+
// Defense-in-depth: /builder-ship passes it from the --verified flag.
|
|
1067
|
+
const confirmTaskRow = await db.getTask(id);
|
|
1068
|
+
if (isVerifyTask(confirmTaskRow) && req.body?.verified !== true) {
|
|
1069
|
+
return res.fail('verify_attestation_required', { status: 409, message: `Task #${id} is a verification task (kind='verify') — confirm it only AFTER the verification ` +
|
|
1070
|
+
`actually passed, with { "verified": true }. Re-run /builder-ship ${id} --verified once it has.` });
|
|
1071
|
+
}
|
|
1072
|
+
if (graderBypassEnabled()) {
|
|
1073
|
+
console.log(`[gds] grader-bypass confirm: builder ${req.builder.id} (${req.builder.rank}) confirming task ${id} with GRADER_BYPASS_ENABLED on`);
|
|
1074
|
+
}
|
|
1075
|
+
try {
|
|
1076
|
+
const result = await db.confirmTask({ taskId: id, builderId: req.builder.id });
|
|
1077
|
+
res.json({ ok: true, ...result });
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
if (err.code === 'TASK_NOT_COMPLETED') {
|
|
1080
|
+
return res.fail('task_not_completed', 409);
|
|
1081
|
+
}
|
|
1082
|
+
console.error('[gds] POST /tasks/:id/confirm', err);
|
|
1083
|
+
res.fail('confirm_failed', { status: 500, message: 'internal error' });
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
// POST /tasks/:id/abandon — terminal "retire this task" transition (task 987).
|
|
1088
|
+
// The lifecycle had promote/ship/confirm/resolve but no first-class way to KILL
|
|
1089
|
+
// a task that is no longer relevant: the only paths to status='abandoned' were
|
|
1090
|
+
// offboarding a builder or a raw SQL UPDATE, so stale/moot tasks lingered in
|
|
1091
|
+
// 'ready' as claimable noise. Refuses on a shipped task (immutable history) and
|
|
1092
|
+
// on a task with an active claim (release it first); idempotent on an already-
|
|
1093
|
+
// abandoned task. The abandon + its `reason` land in audit_log via the writing
|
|
1094
|
+
// middleware, so the ledger keeps a record of who retired what.
|
|
1095
|
+
// rank: archon — terminal lifecycle mutation / scope removal (ADR 0016). This
|
|
1096
|
+
// is the inverse of /promote and belongs to the same archon-gated scope-control
|
|
1097
|
+
// surface, never self-serviceable by a lower rank.
|
|
1098
|
+
router.post('/tasks/:id/abandon', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
|
|
1099
|
+
if (validateOrRespond(req, res, {
|
|
1100
|
+
reason: { type: 'string' }, // free-text retire reason (sliced to 2000 below)
|
|
1101
|
+
})) return;
|
|
1102
|
+
const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
|
|
1103
|
+
if (id === null) return;
|
|
1104
|
+
const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 2000) : '';
|
|
1105
|
+
try {
|
|
1106
|
+
const result = await db.abandonTask({ taskId: id, reason });
|
|
1107
|
+
res.json({ ok: true, ...result });
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
if (err.code === 'TASK_NOT_FOUND') return res.fail('task_not_found', 404);
|
|
1110
|
+
if (err.code === 'CANNOT_ABANDON_SHIPPED') {
|
|
1111
|
+
return res.fail('cannot_abandon_shipped', { status: 409, message: 'shipped tasks are immutable history' });
|
|
1112
|
+
}
|
|
1113
|
+
if (err.code === 'TASK_HAS_ACTIVE_CLAIM') {
|
|
1114
|
+
return res.fail('task_has_active_claim', { status: 409, message: 'release the active claim before abandoning' });
|
|
1115
|
+
}
|
|
1116
|
+
console.error('[gds] POST /tasks/:id/abandon', err);
|
|
1117
|
+
res.fail('abandon_failed', { status: 500, message: 'internal error' });
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
// ==== peer votes (V3.R25 #245) — criterion C2 karma signal sourcing =======
|
|
1122
|
+
// These three sit BEFORE the generic /tasks/:id-shaped routes that take a
|
|
1123
|
+
// numeric id, but Express keys on the literal path so /tasks/peer-votes/feed
|
|
1124
|
+
// (3 segments, non-numeric) never collides with /tasks/:id (2 segments).
|
|
1125
|
+
|
|
1126
|
+
// GET /tasks/peer-votes/feed — recent confirmed/shipped work by OTHER
|
|
1127
|
+
// builders, each with its net vote total and the caller's own current vote,
|
|
1128
|
+
// so the hall can render a vote widget per row. Any builder may view; only
|
|
1129
|
+
// Metic+ may actually vote (enforced on POST below). Excludes the caller's
|
|
1130
|
+
// own ships (you can't vote on yourself).
|
|
1131
|
+
// rank: any-builder — read of the votable feed.
|
|
1132
|
+
router.get('/tasks/peer-votes/feed', auth.requireBuilder, async (req, res) => {
|
|
1133
|
+
// R14 (#2001 / ADR 0119): shared helper; per-endpoint cap preserved (default 25, max 100).
|
|
1134
|
+
const { limit } = api.parsePagination(req, { defaultLimit: 25, maxLimit: 100 });
|
|
1135
|
+
try {
|
|
1136
|
+
const { rows } = await pool.query(
|
|
1137
|
+
`SELECT t.id, t.title, t.value_summary, t.version_id, t.status,
|
|
1138
|
+
t.shipped_at, t.shipped_by,
|
|
1139
|
+
b.github_login, b.display_name,
|
|
1140
|
+
COALESCE(pv.net, 0)::int AS net_votes,
|
|
1141
|
+
mine.vote AS my_vote
|
|
1142
|
+
FROM tasks t
|
|
1143
|
+
LEFT JOIN builders b ON b.id = t.shipped_by
|
|
1144
|
+
LEFT JOIN LATERAL (
|
|
1145
|
+
SELECT SUM(vote) AS net
|
|
1146
|
+
FROM peer_votes WHERE target_task_id = t.id
|
|
1147
|
+
) pv ON true
|
|
1148
|
+
LEFT JOIN peer_votes mine
|
|
1149
|
+
ON mine.target_task_id = t.id AND mine.voter_builder_id = $1
|
|
1150
|
+
WHERE t.status IN ('confirmed', 'shipped')
|
|
1151
|
+
AND t.shipped_by IS NOT NULL
|
|
1152
|
+
AND t.shipped_by <> $1
|
|
1153
|
+
AND t.title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
|
|
1154
|
+
ORDER BY COALESCE(t.shipped_at, t.updated_at) DESC NULLS LAST
|
|
1155
|
+
LIMIT $2`,
|
|
1156
|
+
[req.builder.id, limit]
|
|
1157
|
+
);
|
|
1158
|
+
res.json({ tasks: rows, can_vote: ['metic', 'archon'].includes(req.builder.rank) });
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
// Log the detail server-side; don't return err.message — a raw Postgres
|
|
1161
|
+
// error can leak schema internals (table/column/constraint names).
|
|
1162
|
+
console.error('[gds] GET /tasks/peer-votes/feed', err);
|
|
1163
|
+
res.fail('feed_failed', 500);
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
// POST /tasks/peer-votes/tally — fold matured votes (≥7 days old, not yet
|
|
1168
|
+
// counted) into karma_log, one net row per shipping builder, then mark them
|
|
1169
|
+
// tallied. Idempotent + transactional. Driven by the weekly cron
|
|
1170
|
+
// (.claude/scheduled-tasks/peer-vote-tally/). Archon-gated — it writes karma.
|
|
1171
|
+
// rank: archon — writes karma_log on others' behalf; scope-shaping reputation.
|
|
1172
|
+
// The tally transaction lives in db.tallyPeerVotes() (task 1382, ADR 0078
|
|
1173
|
+
// amendment) so this route AND the server-internal weekly timer (the carved
|
|
1174
|
+
// autonomy module's poller, modules/autonomy/pollers/routine-timers.js, which
|
|
1175
|
+
// reaches it via the doorway api.tallyPeerVotes — BV1.R83) share one code path
|
|
1176
|
+
// and can never diverge. This route keeps the Archon gate (the HTTP surface
|
|
1177
|
+
// stays Archon-only); the in-process timer has no requester to authorize.
|
|
1178
|
+
router.post('/tasks/peer-votes/tally', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
|
|
1179
|
+
try {
|
|
1180
|
+
const result = await db.tallyPeerVotes();
|
|
1181
|
+
res.json(result);
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
console.error('[gds] POST /tasks/peer-votes/tally', err);
|
|
1184
|
+
res.fail('tally_failed', 500);
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
// POST /tasks/:id/vote { vote: 1 | -1 | 0 } — cast/change/clear a peer vote on
|
|
1189
|
+
// a confirmed/shipped task. Metic+ only (Xenos read-only on the project).
|
|
1190
|
+
// - 403 anti-self-vote if the caller shipped the task
|
|
1191
|
+
// - 409 if the task isn't votable, or the vote was already folded into karma
|
|
1192
|
+
// - vote: 0 clears (deletes the row, only while still untallied)
|
|
1193
|
+
// rank: metic+archon — a trusted-builder reputation action.
|
|
1194
|
+
router.post('/tasks/:id/vote', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
|
|
1195
|
+
if (validateOrRespond(req, res, {
|
|
1196
|
+
vote: { required: true, type: 'integer', enum: [-1, 0, 1] },
|
|
1197
|
+
})) return;
|
|
1198
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
1199
|
+
if (id === null) return;
|
|
1200
|
+
const vote = req.body ? req.body.vote : undefined;
|
|
1201
|
+
if (![-1, 0, 1].includes(vote)) {
|
|
1202
|
+
return res.fail('bad_vote', 400, { detail: 'vote must be 1, -1, or 0 (clear)' });
|
|
1203
|
+
}
|
|
1204
|
+
try {
|
|
1205
|
+
const { rows: taskRows } = await pool.query(
|
|
1206
|
+
`SELECT id, status, shipped_by FROM tasks WHERE id = $1`,
|
|
1207
|
+
[id]
|
|
1208
|
+
);
|
|
1209
|
+
if (!taskRows.length) return res.fail('task_not_found', 404);
|
|
1210
|
+
const task = taskRows[0];
|
|
1211
|
+
if (!VOTABLE_TASK_STATUSES.has(task.status)) {
|
|
1212
|
+
return res.fail('task_not_votable', 409, { detail: 'only confirmed or shipped work can be voted on', status: task.status });
|
|
1213
|
+
}
|
|
1214
|
+
if (task.shipped_by == null) {
|
|
1215
|
+
return res.fail('no_shipping_builder', 409, { detail: 'task has no shipped_by to credit' });
|
|
1216
|
+
}
|
|
1217
|
+
if (Number(task.shipped_by) === Number(req.builder.id)) {
|
|
1218
|
+
return res.fail('self_vote_forbidden', 403, { detail: 'you cannot vote on your own work' });
|
|
1219
|
+
}
|
|
1220
|
+
// A vote already folded into karma is frozen. The freeze-check and the
|
|
1221
|
+
// write are done in ONE statement so the weekly tally can't commit
|
|
1222
|
+
// between a separate check and write (no TOCTOU): the conditional
|
|
1223
|
+
// ON CONFLICT … WHERE tallied_at IS NULL only mutates an untallied row,
|
|
1224
|
+
// and RETURNING tells us whether it took. Clearing keeps the same
|
|
1225
|
+
// tallied_at IS NULL guard. (recorded_at is left untouched on change so
|
|
1226
|
+
// the 7-day lag clock isn't reset.)
|
|
1227
|
+
if (vote === 0) {
|
|
1228
|
+
const del = await pool.query(
|
|
1229
|
+
`DELETE FROM peer_votes WHERE voter_builder_id = $1 AND target_task_id = $2 AND tallied_at IS NULL RETURNING id`,
|
|
1230
|
+
[req.builder.id, id]
|
|
1231
|
+
);
|
|
1232
|
+
// A 0-row delete is ambiguous: either there was no vote (harmless
|
|
1233
|
+
// no-op) or the vote is frozen (tallied). Disambiguate so we don't tell
|
|
1234
|
+
// the client "cleared" when a tallied vote actually survived — same
|
|
1235
|
+
// freeze semantics as the cast path below.
|
|
1236
|
+
if (del.rowCount === 0) {
|
|
1237
|
+
const ex = await pool.query(
|
|
1238
|
+
`SELECT 1 FROM peer_votes WHERE voter_builder_id = $1 AND target_task_id = $2`,
|
|
1239
|
+
[req.builder.id, id]
|
|
1240
|
+
);
|
|
1241
|
+
if (ex.rowCount > 0) {
|
|
1242
|
+
return res.fail('vote_already_counted', 409, { detail: 'this vote was folded into karma and can no longer be changed' });
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
} else {
|
|
1246
|
+
const up = await pool.query(
|
|
1247
|
+
`INSERT INTO peer_votes (voter_builder_id, target_task_id, vote)
|
|
1248
|
+
VALUES ($1, $2, $3)
|
|
1249
|
+
ON CONFLICT (voter_builder_id, target_task_id)
|
|
1250
|
+
DO UPDATE SET vote = EXCLUDED.vote
|
|
1251
|
+
WHERE peer_votes.tallied_at IS NULL
|
|
1252
|
+
RETURNING id`,
|
|
1253
|
+
[req.builder.id, id, vote]
|
|
1254
|
+
);
|
|
1255
|
+
// rowCount 0 ⟺ the conflicting row was already tallied (the WHERE
|
|
1256
|
+
// filtered the update and the insert was a no-op on conflict).
|
|
1257
|
+
if (up.rowCount === 0) {
|
|
1258
|
+
return res.fail('vote_already_counted', 409, { detail: 'this vote was folded into karma and can no longer be changed' });
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const { net, total } = await peerVoteNet(id);
|
|
1262
|
+
res.json({ ok: true, task_id: id, my_vote: vote === 0 ? null : vote, net_votes: net, total_votes: total });
|
|
1263
|
+
} catch (err) {
|
|
1264
|
+
console.error('[gds] POST /tasks/:id/vote', err);
|
|
1265
|
+
res.fail('vote_failed', 500);
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
// GDS-V3 task V3.R18 (#209). Subagent grader credits gate.
|
|
1270
|
+
//
|
|
1271
|
+
// ship.js (or any caller) spawns an Opus subagent against the diff + task
|
|
1272
|
+
// description, then POSTs the subagent's JSON to this endpoint. The
|
|
1273
|
+
// endpoint validates+scores the JSON, writes the task_grades row, and —
|
|
1274
|
+
// if both functional_fidelity and code_quality clear threshold — promotes
|
|
1275
|
+
// the task from completed→confirmed and awards credits.
|
|
1276
|
+
//
|
|
1277
|
+
// Request body:
|
|
1278
|
+
// {
|
|
1279
|
+
// subagent_output: <object or string — the subagent's JSON>,
|
|
1280
|
+
// signals?: <object — optional run metadata: model, run_ms, etc.>
|
|
1281
|
+
// }
|
|
1282
|
+
//
|
|
1283
|
+
// Failure modes (none of which throw — all return a graded row + 200):
|
|
1284
|
+
// - malformed subagent JSON → passed=false, schema/parse_error in signals
|
|
1285
|
+
// - functional_fidelity < threshold → passed=false
|
|
1286
|
+
// - code_quality < threshold → passed=false
|
|
1287
|
+
// - confidence < low-confidence cap → passed=false
|
|
1288
|
+
// Any of these leaves the task at `completed`; builder can manual-confirm
|
|
1289
|
+
// via POST /tasks/:id/confirm if they think the grade was wrong.
|
|
1290
|
+
// rank: any-builder — grader writes a row + auto-promotes only if grade clears
|
|
1291
|
+
// threshold; the grader subagent is the objectivity guarantee. Supervisor hot
|
|
1292
|
+
// path (preserved per CRITICAL §4 — no requireRank).
|
|
1293
|
+
router.post('/tasks/:id/grade', auth.requireBuilder, async (req, res) => {
|
|
1294
|
+
// Permissive on subagent_output type — the grader handles both object AND
|
|
1295
|
+
// JSON-string forms (see tests/grader.mjs "accepts JSON-string input").
|
|
1296
|
+
// Only enforce presence + light shape on signals (extras pass through).
|
|
1297
|
+
if (validateOrRespond(req, res, {
|
|
1298
|
+
// subagent_output is an object OR a JSON string — the grader handles both
|
|
1299
|
+
// (tests/grader.mjs); declared untyped so strict-mode accepts it (its deep
|
|
1300
|
+
// shape is validated by the grader, and its presence by the guard below).
|
|
1301
|
+
subagent_output: {},
|
|
1302
|
+
signals: { type: 'object' },
|
|
1303
|
+
// touches[] cleanup [3/8] (task 878): ship.js sends the real committed
|
|
1304
|
+
// changed-files so the shipper badges key off the diff, not touches[].
|
|
1305
|
+
committed_files: { type: 'array', maxItems: 5000, itemsType: 'string' },
|
|
1306
|
+
diff_hash: { type: 'string', maxLength: 128 },
|
|
1307
|
+
})) return;
|
|
1308
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
1309
|
+
if (id === null) return;
|
|
1310
|
+
|
|
1311
|
+
// SECURITY (#871): only the claim holder (or an Archon for --regrade) may
|
|
1312
|
+
// grade — a builder must not be able to grade work they never claimed.
|
|
1313
|
+
if (!(await gateTaskOwnership(req, res, id))) return;
|
|
1314
|
+
|
|
1315
|
+
const { subagent_output: subagentOutput, signals, committed_files: committedFiles, diff_hash: diffHash } = req.body || {};
|
|
1316
|
+
if (subagentOutput == null) {
|
|
1317
|
+
return res.fail('subagent_output_required', 400);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// grading carved to modules/grading/ (BV1.R73 / ADR 0091 §4): resolve the
|
|
1321
|
+
// `grade` kernel PORT. resolveOptional (not resolve) so a grading-disabled
|
|
1322
|
+
// instance returns a clean 503 rather than a thrown required-port error;
|
|
1323
|
+
// on OTB grading is default-on, so this is byte-identical to the old import.
|
|
1324
|
+
const grading = seams.resolveOptional('grade');
|
|
1325
|
+
if (!grading) {
|
|
1326
|
+
return res.fail('grading_unavailable', { status: 503, message: 'grading is disabled on this instance' });
|
|
1327
|
+
}
|
|
1328
|
+
const clientGrade = grading.validateAndScore(subagentOutput, signals || {});
|
|
1329
|
+
|
|
1330
|
+
// SR-9 / SR-18 (task 992): server-authoritative hardening. validateAndScore
|
|
1331
|
+
// above computes `passed` from the CLIENT's submitted scores — the real
|
|
1332
|
+
// adversarial panel + the deterministic pre-passes run client-side in
|
|
1333
|
+
// ship.js and the server never re-runs them, so a builder could POST a
|
|
1334
|
+
// forged {ff:10,cq:10,issues:[]} to self-pass. serverSideGuards re-runs the
|
|
1335
|
+
// two DETERMINISTIC pre-passes here (route-rank against the server's own
|
|
1336
|
+
// route files; permission-path against the caller's LIVE, authoritative
|
|
1337
|
+
// rank — req.builder.rank, read fresh from the DB per request), rejects an
|
|
1338
|
+
// implausible empty-issues PASS on a non-trivial diff, and neutralizes an
|
|
1339
|
+
// obviously-forged net-negative credit signal (SR-18). It is DOWNGRADE-ONLY:
|
|
1340
|
+
// it can flip a client PASS to a hard FAIL or manual-confirm, but never
|
|
1341
|
+
// upgrades a fail and never relaxes the existing gate — so a legitimate
|
|
1342
|
+
// honest grade (and the Archon / GRADER_BYPASS confirm path, which lands via
|
|
1343
|
+
// /tasks/:id/confirm, NOT this route) is unaffected.
|
|
1344
|
+
const gradeResult = grading.serverSideGuards(clientGrade, {
|
|
1345
|
+
committedFiles: Array.isArray(committedFiles) ? committedFiles : null,
|
|
1346
|
+
builderRank: req.builder && req.builder.rank, // SERVER-KNOWN authoritative rank
|
|
1347
|
+
diffStats: (signals && signals.diff_stats) || null,
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
try {
|
|
1351
|
+
const result = await db.applyGrade({
|
|
1352
|
+
taskId: id,
|
|
1353
|
+
builderId: req.builder.id,
|
|
1354
|
+
gradeResult,
|
|
1355
|
+
committedFiles: Array.isArray(committedFiles) ? committedFiles : null,
|
|
1356
|
+
diffHash: typeof diffHash === 'string' && diffHash.length > 0 ? diffHash : null,
|
|
1357
|
+
});
|
|
1358
|
+
res.json({ ok: true, ...result, just_unlocked: result.justUnlocked ?? [] });
|
|
1359
|
+
} catch (err) {
|
|
1360
|
+
if (err.code === 'TASK_NOT_FOUND') return res.fail('task_not_found', 404);
|
|
1361
|
+
if (err.code === 'TASK_NOT_GRADABLE') {
|
|
1362
|
+
return res.fail('task_not_gradable', 409, { current_status: err.current_status });
|
|
1363
|
+
}
|
|
1364
|
+
console.error('[gds] POST /tasks/:id/grade', err);
|
|
1365
|
+
res.fail('grade_failed', { status: 500, message: 'internal error' });
|
|
1366
|
+
}
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
// POST /tasks/:id/override-request — V3.R74 (#293). The rare path: a builder
|
|
1370
|
+
// whose ship failed the subagent grader believes the grader was wrong and
|
|
1371
|
+
// asks an Archon to flip the task to confirmed anyway. Audit-logged via the
|
|
1372
|
+
// global write-audit middleware. Open requests surface in the Archon queue.
|
|
1373
|
+
// rank: any-builder — opens a request; the decision still rests with Archon.
|
|
1374
|
+
router.post('/tasks/:id/override-request', auth.requireBuilder, async (req, res) => {
|
|
1375
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
1376
|
+
if (id === null) return;
|
|
1377
|
+
if (validateOrRespond(req, res, {
|
|
1378
|
+
rationale: { required: true, type: 'string', minLength: 20, maxLength: 4000 },
|
|
1379
|
+
})) return;
|
|
1380
|
+
// SECURITY (SR-16 / #996 — mirrors the #871 gate on /grade, /confirm, /ship):
|
|
1381
|
+
// only the task's most-recent claim holder (or an Archon) may file an
|
|
1382
|
+
// override-request. Without this, a SECOND builder could open a request on
|
|
1383
|
+
// another builder's `completed` task; on Archon approval that historically
|
|
1384
|
+
// routed the credit + ship attribution toward the requester, so the gate is
|
|
1385
|
+
// the front-line defence against cross-builder credit/attribution theft (the
|
|
1386
|
+
// decideOverrideRequest attribution fix from #538 is the second layer). An
|
|
1387
|
+
// Archon always passes; a never-claimed task falls back to ALLOW so a legit
|
|
1388
|
+
// edge case is never wedged.
|
|
1389
|
+
if (!(await gateTaskOwnership(req, res, id))) return;
|
|
1390
|
+
try {
|
|
1391
|
+
const row = await db.createOverrideRequest({
|
|
1392
|
+
taskId: id,
|
|
1393
|
+
requesterId: req.builder.id,
|
|
1394
|
+
rationale: req.body.rationale.trim(),
|
|
1395
|
+
});
|
|
1396
|
+
res.status(201).json({ ok: true, request: row });
|
|
1397
|
+
} catch (err) {
|
|
1398
|
+
if (err.code === 'TASK_NOT_FOUND') return res.fail('task_not_found', 404);
|
|
1399
|
+
if (err.code === 'TASK_NOT_COMPLETED') {
|
|
1400
|
+
return res.fail('task_not_completed', 409, { current_status: err.currentStatus });
|
|
1401
|
+
}
|
|
1402
|
+
if (err.code === 'OPEN_REQUEST_EXISTS') {
|
|
1403
|
+
return res.fail('open_request_exists', 409);
|
|
1404
|
+
}
|
|
1405
|
+
console.error('[gds] POST /tasks/:id/override-request', err);
|
|
1406
|
+
res.fail('override_request_failed', { status: 500, message: 'internal error' });
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
// ==========================================================================
|
|
1411
|
+
// Server-mediated branch publish (ADR 0055 / task 1025)
|
|
1412
|
+
// --------------------------------------------------------------------------
|
|
1413
|
+
// A builder on a cloud dev box has NO GitHub push credential (#919/ADR 0053
|
|
1414
|
+
// box-scope + credential-less origin), so in `ci` deploy mode ship.js cannot
|
|
1415
|
+
// `git push` + `gh pr create`. Instead the box uploads its branch as a THIN
|
|
1416
|
+
// git bundle here; the server pushes it + opens the PR + enables auto-merge
|
|
1417
|
+
// using its own push credential — a GitHub App installation token (preferred,
|
|
1418
|
+
// task 1028) or the GITHUB_PUSH_TOKEN PAT fallback; single repo, Contents:write
|
|
1419
|
+
// + PR:write + Actions:read — it CANNOT push to branch-protected main nor bypass
|
|
1420
|
+
// required checks. The box holds NO github credential, so the #919 model is
|
|
1421
|
+
// preserved.
|
|
1422
|
+
//
|
|
1423
|
+
// AUTH: requireBuilder + gateTaskOwnership, deliberately NOT allowBoxScope — a
|
|
1424
|
+
// box-scoped session is 403'd here (it must `/builder-reauth` to a real cli
|
|
1425
|
+
// session first). The grade already ran client-side against this exact HEAD
|
|
1426
|
+
// (ship.js grades BEFORE this call), and the bundle-tip == claimed head_sha
|
|
1427
|
+
// check below binds the pushed code to that graded sha.
|
|
1428
|
+
const publishUpload = express.json({ limit: PUBLISH_BODY_LIMIT });
|
|
1429
|
+
router.post('/tasks/:id/publish-branch', auth.requireBuilder, publishUpload, async (req, res) => {
|
|
1430
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
1431
|
+
if (id === null) return;
|
|
1432
|
+
if (validateOrRespond(req, res, {
|
|
1433
|
+
branch: { required: true, type: 'string', maxLength: 200, minLength: 1 },
|
|
1434
|
+
head_sha: { required: true, type: 'string', maxLength: 64, minLength: 7 },
|
|
1435
|
+
bundle_b64: { required: true, type: 'string', maxLength: PUBLISH_BUNDLE_B64_MAX },
|
|
1436
|
+
value_summary: { type: 'string', maxLength: LIMITS.VALUE_SUMMARY },
|
|
1437
|
+
})) return;
|
|
1438
|
+
// SECURITY (#871 gate, same as /grade /confirm /ship): only the claim holder
|
|
1439
|
+
// (or an Archon) may publish this task's branch.
|
|
1440
|
+
if (!(await gateTaskOwnership(req, res, id))) return;
|
|
1441
|
+
if (!githubPush.isConfigured()) {
|
|
1442
|
+
return res.fail('push_unconfigured', { status: 503, message: 'Server-mediated push is not configured (no GitHub App nor GITHUB_PUSH_TOKEN on the server). ' +
|
|
1443
|
+
'Ask the operator to provision it; meanwhile ship from a machine with its own git push credentials.' });
|
|
1444
|
+
}
|
|
1445
|
+
const { branch, head_sha: headSha, value_summary: valueSummary } = req.body;
|
|
1446
|
+
if (!githubPush.isSafeBranch(branch)) return res.fail('bad_branch', 400);
|
|
1447
|
+
if (!/^[0-9a-f]{7,64}$/.test(headSha)) return res.fail('bad_head_sha', 400);
|
|
1448
|
+
|
|
1449
|
+
// Decode + byte-bound the bundle. The JSON `limit` bounds the ENCODED payload;
|
|
1450
|
+
// base64 inflates ~33%, so cap the DECODED bytes too (SR-17 disk-fill posture).
|
|
1451
|
+
let bundleBuf;
|
|
1452
|
+
try {
|
|
1453
|
+
bundleBuf = Buffer.from(req.body.bundle_b64, 'base64');
|
|
1454
|
+
} catch (_) {
|
|
1455
|
+
return res.fail('bad_bundle_encoding', 400);
|
|
1456
|
+
}
|
|
1457
|
+
if (!bundleBuf.length) return res.fail('empty_bundle', 400);
|
|
1458
|
+
if (bundleBuf.length > PUBLISH_BUNDLE_MAX_BYTES) {
|
|
1459
|
+
return res.fail('bundle_too_large', 413, { max_bytes: PUBLISH_BUNDLE_MAX_BYTES });
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-publish-in-'));
|
|
1463
|
+
const bundlePath = path.join(tmpDir, 'branch.bundle');
|
|
1464
|
+
try {
|
|
1465
|
+
fs.writeFileSync(bundlePath, bundleBuf);
|
|
1466
|
+
const { head_sha: pushedSha } = await githubPush.pushBundle({ bundlePath, branch, headSha });
|
|
1467
|
+
const title = `task ${id}: ${deHashRefs(valueSummary) || branch}`.slice(0, 240);
|
|
1468
|
+
const body =
|
|
1469
|
+
`Auto-opened by GDS server-mediated publish (ADR 0055) for task ${id}.\n\n` +
|
|
1470
|
+
`${deHashRefs(valueSummary) || '(no value summary)'}\n\n` +
|
|
1471
|
+
`Merging triggers .github/workflows/deploy-prod.yml -> ${branding().domains.publicOrigin}. ` +
|
|
1472
|
+
`Auto-merge is enabled — GitHub lands it once the required checks pass.`;
|
|
1473
|
+
const pr = await githubPush.openOrFindPullRequest({ branch, title, body });
|
|
1474
|
+
const autoMerge = await githubPush.enableAutoMerge({ nodeId: pr.node_id });
|
|
1475
|
+
// task 1139 (ADR 0058): attest the author's trust tier on the pushed sha so the
|
|
1476
|
+
// gate-review check can auto-approve a Metic+ author's gate-surface PR with no
|
|
1477
|
+
// owner click. Only the SERVER can set this (it holds req.builder's server-
|
|
1478
|
+
// resolved rank + the App credential) — CI cannot forge it, and a PR-title rank
|
|
1479
|
+
// lookup would be spoofable. Shared with POST /tasks/:id/attest-gate (the laptop
|
|
1480
|
+
// ci-land path — task 1169). Best-effort: a failure leaves the PR un-attested,
|
|
1481
|
+
// so it escalates (the safe direction).
|
|
1482
|
+
await postGateAuthorTrust({ rank: req.builder.rank, sha: pushedSha, taskId: id });
|
|
1483
|
+
// task 1547: persist the tip we ACTUALLY pushed so the confirmed->shipped
|
|
1484
|
+
// reconcile proves THIS task's land from its own commit, not from a (reusable)
|
|
1485
|
+
// branch name. Without it, a reused worktree branch can match an older task's
|
|
1486
|
+
// already-merged PR and false-flip confirmed->shipped (the BV1.R04/R05 strand:
|
|
1487
|
+
// branch claude/jovial-rosalind-84d6c3 had earlier been PR #386 / task 1297).
|
|
1488
|
+
// Best-effort: a miss just falls the reconcile back to the PR-ref-this-task check.
|
|
1489
|
+
try {
|
|
1490
|
+
await db.setClaimPublishedSha({ taskId: id, builderId: req.builder.id, sha: pushedSha });
|
|
1491
|
+
} catch (e) {
|
|
1492
|
+
console.error('[gds] setClaimPublishedSha (non-blocking)', id, e && e.message);
|
|
1493
|
+
}
|
|
1494
|
+
return res.status(201).json({
|
|
1495
|
+
ok: true, pr_url: pr.url, pr_number: pr.number, head_sha: pushedSha, auto_merge: autoMerge,
|
|
1496
|
+
});
|
|
1497
|
+
} catch (e) {
|
|
1498
|
+
if (e.code === 'TIP_MISMATCH') {
|
|
1499
|
+
return res.fail('tip_mismatch', { status: 400, message: `The uploaded bundle's tip (${String(e.tip || '').slice(0, 8)}) does not match the ` +
|
|
1500
|
+
`claimed head_sha (${headSha.slice(0, 8)}). Commit your work and re-ship from a clean tree.` });
|
|
1501
|
+
}
|
|
1502
|
+
if (e.code === 'BAD_BRANCH') return res.fail('bad_branch', 400);
|
|
1503
|
+
if (e.code === 'BAD_SHA') return res.fail('bad_head_sha', 400);
|
|
1504
|
+
if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
|
|
1505
|
+
if (e.code === 'PUSH_UNCONFIGURED') return res.fail('push_unconfigured', 503);
|
|
1506
|
+
if (e.code === 'PR_CREATE_FAILED') return res.fail('pr_create_failed', 502, { detail: e.detail });
|
|
1507
|
+
// task 1547: GIT_FAILED now carries WHICH git step failed + the credential-
|
|
1508
|
+
// scrubbed stderr (github-push.defaultExec). Log both so the next push outage
|
|
1509
|
+
// is a one-line diagnosis, not a multi-hour opaque-`publish_failed` mystery
|
|
1510
|
+
// (the BV1.R04/R05 incident: ~2h of bare GIT_FAILED with no cause). Return the
|
|
1511
|
+
// step + a short scrubbed reason to the (ownership-gated) builder too.
|
|
1512
|
+
console.error(
|
|
1513
|
+
'[gds] POST /tasks/:id/publish-branch', e.code || e.message,
|
|
1514
|
+
e.git_step ? `step=${e.git_step}` : '',
|
|
1515
|
+
e.stderr ? `stderr=${e.stderr}` : ''
|
|
1516
|
+
);
|
|
1517
|
+
return res.fail('publish_failed', 500, { ...(e.git_step ? { step: e.git_step } : {}), ...(e.stderr ? { reason: String(e.stderr).slice(0, 200) } : {}) });
|
|
1518
|
+
} finally {
|
|
1519
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
|
|
1520
|
+
}
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
// GET /tasks/:id/publish-status?branch=<branch> — poll the PR + prod-deploy
|
|
1524
|
+
// state for a server-mediated publish so ship.js can confirm the land WITHOUT
|
|
1525
|
+
// gh on the box. Ownership-gated like the POST. Derives state live from GitHub.
|
|
1526
|
+
router.get('/tasks/:id/publish-status', auth.requireBuilder, async (req, res) => {
|
|
1527
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
1528
|
+
if (id === null) return;
|
|
1529
|
+
if (!(await gateTaskOwnership(req, res, id))) return;
|
|
1530
|
+
if (!githubPush.isConfigured()) return res.fail('push_unconfigured', 503);
|
|
1531
|
+
const branch = req.query.branch;
|
|
1532
|
+
if (!githubPush.isSafeBranch(branch)) return res.fail('bad_branch', 400);
|
|
1533
|
+
try {
|
|
1534
|
+
const status = await githubPush.getPublishStatus({ branch });
|
|
1535
|
+
// task 1049 (B — server-side reconcile-on-read): when GitHub shows the PR
|
|
1536
|
+
// MERGED and deploy-prod SUCCEEDED, finish the task right here so a
|
|
1537
|
+
// confirmed-but-unshipped task never strands waiting on a human to re-poll
|
|
1538
|
+
// (the #1027 retrospective: a successful publish left the task at confirmed
|
|
1539
|
+
// forever). Idempotent + safe: db.shipTask is the exact flip POST /ship
|
|
1540
|
+
// runs, throws TASK_NOT_CONFIRMED when the task isn't at 'confirmed'
|
|
1541
|
+
// (already shipped, or never confirmed) — which we swallow. Same authority
|
|
1542
|
+
// as /ship: this route is gateTaskOwnership-gated.
|
|
1543
|
+
let reconciled = false;
|
|
1544
|
+
let reconcile_blocked = null;
|
|
1545
|
+
// task 1547: landConfirmed alone is NOT enough — getPublishStatus matches the
|
|
1546
|
+
// most-recent PR for a branch NAME, and worktree branch names get reused, so a
|
|
1547
|
+
// merged-but-OLDER PR can satisfy landConfirmed for a task whose own push never
|
|
1548
|
+
// landed (the BV1.R04/R05 false-ship off PR #386 / task 1297). Require proof that
|
|
1549
|
+
// THIS task's published tip is in main before flipping confirmed->shipped.
|
|
1550
|
+
if (githubPush.landConfirmed(status)) {
|
|
1551
|
+
const publishedSha = await db.getClaimPublishedSha(id);
|
|
1552
|
+
const proof = await githubPush.landProvenForTask({ taskId: id, publishedSha, status });
|
|
1553
|
+
if (!proof.proven) {
|
|
1554
|
+
reconcile_blocked = proof.reason;
|
|
1555
|
+
console.warn(`[gds] publish-status reconcile BLOCKED task ${id}: ${proof.reason}`);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
if (githubPush.landConfirmed(status) && !reconcile_blocked) {
|
|
1559
|
+
try {
|
|
1560
|
+
const shipped = await db.shipTask({ taskId: id, builderId: req.builder.id });
|
|
1561
|
+
reconciled = true;
|
|
1562
|
+
// task 1045: the box that published can't post the Discord broadcast —
|
|
1563
|
+
// it holds no webhook secret (ADR 0053 "box holds nothing"), so a box
|
|
1564
|
+
// ship was silently skipping the public announcement. The server holds
|
|
1565
|
+
// the secrets and just confirmed the deploy, so it fires the broadcast
|
|
1566
|
+
// here — exactly ONCE, on the confirmed→shipped transition. shipTask
|
|
1567
|
+
// throws TASK_NOT_CONFIRMED on a repeat poll, so this never re-posts.
|
|
1568
|
+
// Best-effort: emit task.shipped; the discord module (if enabled) posts
|
|
1569
|
+
// the #ship-news / #ship-feed broadcast on its listener. emitAsync
|
|
1570
|
+
// isolates listener errors, so a broadcast hiccup can never turn a
|
|
1571
|
+
// successful reconcile into a 502 (ADR 0083 / BV1.R47 — replaces the old
|
|
1572
|
+
// direct require('../ship-broadcast') core→discord import).
|
|
1573
|
+
await seams.emitAsync('task.shipped', shipped);
|
|
1574
|
+
} catch (err) {
|
|
1575
|
+
if (err.code !== 'TASK_NOT_CONFIRMED') throw err;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
// task 1162: if GitHub still shows the PR open (not merged) and we didn't
|
|
1579
|
+
// just ship, GitHub's auto-merge daemon may have stalled on an already-green
|
|
1580
|
+
// PR (the PR #125 case: 7/7 checks green, CLEAN, never merged). NUDGE the
|
|
1581
|
+
// merge server-side AND surface the real blocker, so a credential-less
|
|
1582
|
+
// session reports the truth (pending/failing check, review required) instead
|
|
1583
|
+
// of guessing a non-existent gate and asking the owner to click merge. This
|
|
1584
|
+
// mirrors the reconcile-on-read precedent above (a GET that finishes the
|
|
1585
|
+
// land); best-effort so a merge-driver miss never turns the poll into a 502.
|
|
1586
|
+
let merge_driver = null;
|
|
1587
|
+
if (!reconciled && !status.merged && status.pr_state === 'open') {
|
|
1588
|
+
try {
|
|
1589
|
+
merge_driver = await githubPush.mergeIfGreen({ branch });
|
|
1590
|
+
} catch (e) {
|
|
1591
|
+
console.error('[gds] publish-status merge-driver (non-blocking):', e && e.message);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return res.json({ ok: true, ...status, reconciled, reconcile_blocked, merge_driver });
|
|
1595
|
+
} catch (e) {
|
|
1596
|
+
if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
|
|
1597
|
+
console.error('[gds] GET /tasks/:id/publish-status', e.code || e.message);
|
|
1598
|
+
return res.fail('status_failed', 502);
|
|
1599
|
+
}
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
// POST /tasks/:id/attest-gate — body: { head_sha }. The LAPTOP ci-land analogue
|
|
1603
|
+
// of the publish-branch attestation (task 1169). ship.js's local push path
|
|
1604
|
+
// (pushVia 'local') pushes the branch + opens the PR with its OWN gh credential
|
|
1605
|
+
// but CANNOT forge the trustless gate-author-trust commit status — only the server
|
|
1606
|
+
// can (it holds req.builder's server-resolved rank). Without this, an ENFORCING
|
|
1607
|
+
// ADR-0058 gate-review BLOCKS a Metic+ author's gate-surface PR shipped from a
|
|
1608
|
+
// laptop and they can't self-approve their own PR (hit live on task 1162 / PR #131).
|
|
1609
|
+
//
|
|
1610
|
+
// AUTH mirrors publish-branch EXACTLY: requireBuilder + gateTaskOwnership, and
|
|
1611
|
+
// deliberately NOT allowBoxScope (a box-scoped session 403s — it must reauth). The
|
|
1612
|
+
// attestation power is bounded: gate-author-trust clears a NEEDS_TRUST surface for
|
|
1613
|
+
// a Metic+ author only and NEVER the hard floor (which always needs an owner
|
|
1614
|
+
// review), so a Metic+ vouching for their own task's head stays within their tier.
|
|
1615
|
+
router.post('/tasks/:id/attest-gate', auth.requireBuilder, async (req, res) => {
|
|
1616
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
1617
|
+
if (id === null) return;
|
|
1618
|
+
if (validateOrRespond(req, res, {
|
|
1619
|
+
head_sha: { required: true, type: 'string', maxLength: 64, minLength: 7 },
|
|
1620
|
+
})) return;
|
|
1621
|
+
if (!(await gateTaskOwnership(req, res, id))) return;
|
|
1622
|
+
const { head_sha: headSha } = req.body;
|
|
1623
|
+
if (!/^[0-9a-f]{7,64}$/.test(headSha)) return res.fail('bad_head_sha', 400);
|
|
1624
|
+
if (!githubPush.isConfigured()) {
|
|
1625
|
+
return res.fail('push_unconfigured', { status: 503, message: 'No server push credential (GitHub App / GITHUB_PUSH_TOKEN) to post the gate attestation. ' +
|
|
1626
|
+
'A gate-surface PR will need an owner review until the operator provisions it.' });
|
|
1627
|
+
}
|
|
1628
|
+
const { attested, reason } = await postGateAuthorTrust({ rank: req.builder.rank, sha: headSha, taskId: id });
|
|
1629
|
+
return res.json({ ok: true, attested, reason });
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
// GET /override-requests?status=open — Archon queue. Status defaults to open;
|
|
1633
|
+
// pass ?status=approved or ?status=denied for the resolved history.
|
|
1634
|
+
// rank: archon — same gate as the audit log + Archon view.
|
|
1635
|
+
router.get('/override-requests', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
|
|
1636
|
+
const status = req.query.status || 'open';
|
|
1637
|
+
if (!['open', 'approved', 'denied'].includes(status)) {
|
|
1638
|
+
return res.fail('bad_status', 400);
|
|
1639
|
+
}
|
|
1640
|
+
try {
|
|
1641
|
+
const rows = await db.listOverrideRequests({ status });
|
|
1642
|
+
res.json({ requests: rows });
|
|
1643
|
+
} catch (err) {
|
|
1644
|
+
console.error('[gds] GET /override-requests', err);
|
|
1645
|
+
res.fail('list_failed', { status: 500, message: 'internal error' });
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
// POST /override-requests/:id/decide — body: { decision: 'approve'|'deny', note? }
|
|
1650
|
+
// On approve, atomically flips the task completed→confirmed and awards the
|
|
1651
|
+
// credits to the original requester (NOT the Archon). On deny, only the row
|
|
1652
|
+
// updates; the task stays at completed and the builder must release or
|
|
1653
|
+
// re-ship after fixing whatever the grader caught.
|
|
1654
|
+
// rank: archon — grader-bypass authority; same gate as POST /tasks/:id/confirm.
|
|
1655
|
+
router.post('/override-requests/:id/decide', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
|
|
1656
|
+
const id = parseId(req, res, { code: 'bad_request_id' });
|
|
1657
|
+
if (id === null) return;
|
|
1658
|
+
if (validateOrRespond(req, res, {
|
|
1659
|
+
decision: { required: true, type: 'string' },
|
|
1660
|
+
note: { type: 'string', maxLength: 4000 },
|
|
1661
|
+
})) return;
|
|
1662
|
+
const { decision, note } = req.body || {};
|
|
1663
|
+
if (decision !== 'approve' && decision !== 'deny') {
|
|
1664
|
+
return res.fail('bad_decision', { status: 400, message: "decision must be 'approve' or 'deny'" });
|
|
1665
|
+
}
|
|
1666
|
+
try {
|
|
1667
|
+
const result = await db.decideOverrideRequest({
|
|
1668
|
+
requestId: id,
|
|
1669
|
+
archonId: req.builder.id,
|
|
1670
|
+
decision,
|
|
1671
|
+
note,
|
|
1672
|
+
});
|
|
1673
|
+
res.json({ ok: true, ...result });
|
|
1674
|
+
} catch (err) {
|
|
1675
|
+
if (err.code === 'REQUEST_NOT_OPEN') {
|
|
1676
|
+
return res.fail('request_not_open', 409);
|
|
1677
|
+
}
|
|
1678
|
+
if (err.code === 'OVERRIDE_NO_REQUESTER') {
|
|
1679
|
+
// Data-integrity guard (#538): an approved override with no requester to
|
|
1680
|
+
// credit. Should never happen (NOT NULL column); refuse rather than
|
|
1681
|
+
// misattribute. 409 — the request is left open for inspection.
|
|
1682
|
+
return res.fail('override_no_requester', { status: 409, message: 'internal error' });
|
|
1683
|
+
}
|
|
1684
|
+
console.error('[gds] POST /override-requests/:id/decide', err);
|
|
1685
|
+
res.fail('decide_failed', { status: 500, message: 'internal error' });
|
|
1686
|
+
}
|
|
1687
|
+
});
|
|
1688
|
+
|
|
1689
|
+
return router;
|
|
1690
|
+
};
|
|
1691
|
+
|
|
1692
|
+
// Exported for unit testing + so callers can reuse the exact detection logic.
|
|
1693
|
+
module.exports.detectsMigration = detectsMigration;
|
|
1694
|
+
module.exports.graderBypassEnabled = graderBypassEnabled;
|
|
1695
|
+
module.exports.MIGRATION_KEYWORD_RE = MIGRATION_KEYWORD_RE;
|
|
1696
|
+
// #871: exported so tests/task_ownership_gate.mjs can drive the claim-ownership
|
|
1697
|
+
// gate directly (with a stubbed db.getLastClaimHolder) — no server/DB needed.
|
|
1698
|
+
module.exports.gateTaskOwnership = gateTaskOwnership;
|
|
1699
|
+
// task 1053 (F): exported so the verify-attestation guard is unit-tested.
|
|
1700
|
+
module.exports.isVerifyTask = isVerifyTask;
|
|
1701
|
+
// task 1139 / 1169 (ADR 0058): exported so the pure rank-attestation decision
|
|
1702
|
+
// (Metic+ → gate-author-trust) is unit-tested without a server/DB/network.
|
|
1703
|
+
module.exports.rankAttestsGateTrust = rankAttestsGateTrust;
|