@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,3028 @@
|
|
|
1
|
+
// public-builders/builders.js — the authenticated /builders single-page UI.
|
|
2
|
+
//
|
|
3
|
+
// Read-only across the wire EXCEPT the per-task copy-prompt button, which is
|
|
4
|
+
// purely client-side (writes to the clipboard, no server call).
|
|
5
|
+
//
|
|
6
|
+
// Auth model: cookie-based (gds_session, set by /api/bongos/auth/web/callback;
|
|
7
|
+
// the legacy pms_session cookie is still accepted on read during V3 rollout).
|
|
8
|
+
// On a 401 from /api/bongos/me we render an unauth landing card with a Sign-in
|
|
9
|
+
// button; on a 401 from any other endpoint we redirect straight into the web
|
|
10
|
+
// flow (the user was authenticated and now isn't — likely an expired session).
|
|
11
|
+
|
|
12
|
+
(() => {
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const API = '/api/bongos';
|
|
16
|
+
// Generated typed client (task 1996 / R09), served as window.BongosClient by
|
|
17
|
+
// /lib/bongos-client.js. baseUrl:'' passes the full same-origin paths this file
|
|
18
|
+
// builds through unchanged; result-mode ({ status, ok, data }) preserves control flow 1:1.
|
|
19
|
+
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
20
|
+
const AUTH_NEEDED = 'auth-needed';
|
|
21
|
+
|
|
22
|
+
// moduleOn(name) — feature-module gate (R60, task 1199), shared via window.OTB
|
|
23
|
+
// (dom-utils.js). Reads the injected window.__MODULES__; defaults to TRUE when a
|
|
24
|
+
// module flag is missing so the full UI shows rather than silently hiding.
|
|
25
|
+
const { moduleOn } = window.OTB;
|
|
26
|
+
|
|
27
|
+
// ---- tiny helpers --------------------------------------------------------
|
|
28
|
+
// escapeHtml / $ / fmtNum / fmtDate / rankLabel / toast now come from the
|
|
29
|
+
// shared window.OTB toolkit (dom-utils.js, loaded first). Thin wrappers keep
|
|
30
|
+
// this file's exact variants (date-only fmtDate; '—' rankLabel empty; a
|
|
31
|
+
// 2200ms clearing toast). renderRankBadge + RANK_ORDER stay local below.
|
|
32
|
+
const { escapeHtml, $, fmtNum } = window.OTB;
|
|
33
|
+
const fmtDate = (iso) => window.OTB.fmtDate(iso);
|
|
34
|
+
const rankLabel = (rank) => window.OTB.rankLabel(rank);
|
|
35
|
+
const toast = (msg) => window.OTB.toast(msg, { duration: 2200, clear: true });
|
|
36
|
+
|
|
37
|
+
// Instance strings from the server-injected pack (window.__BRANDING__ is set
|
|
38
|
+
// synchronously in <head>, before this script) — never a hardcoded host brand
|
|
39
|
+
// (ADR 0062 §3). Neutral fallbacks so a missing pack degrades gracefully.
|
|
40
|
+
const brandWorld = () => (window.__BRANDING__ && window.__BRANDING__.identity && window.__BRANDING__.identity.worldName) || 'the project';
|
|
41
|
+
const curLabel = (caps) => {
|
|
42
|
+
const l = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits';
|
|
43
|
+
return caps ? l.charAt(0).toUpperCase() + l.slice(1) : l;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
async function getJSON(path, opts = {}) {
|
|
47
|
+
const res = await api.request('GET', path);
|
|
48
|
+
if (res.status === 401) {
|
|
49
|
+
if (opts.softAuth) {
|
|
50
|
+
const err = new Error(AUTH_NEEDED);
|
|
51
|
+
err.code = AUTH_NEEDED;
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
window.location.assign(`${API}/auth/web/start`);
|
|
55
|
+
throw new Error('redirecting to auth');
|
|
56
|
+
}
|
|
57
|
+
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
58
|
+
return res.data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function patchJSON(path, body) {
|
|
62
|
+
const res = await api.request('PATCH', path, { body });
|
|
63
|
+
if (res.status === 401) {
|
|
64
|
+
window.location.assign(`${API}/auth/web/start`);
|
|
65
|
+
throw new Error('redirecting to auth');
|
|
66
|
+
}
|
|
67
|
+
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
68
|
+
return res.data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function postJSON(path, body) {
|
|
72
|
+
const res = await api.request('POST', path, { body });
|
|
73
|
+
if (res.status === 401) {
|
|
74
|
+
window.location.assign(`${API}/auth/web/start`);
|
|
75
|
+
throw new Error('redirecting to auth');
|
|
76
|
+
}
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
let detail = `${path} → ${res.status}`;
|
|
79
|
+
let code = null;
|
|
80
|
+
try {
|
|
81
|
+
const j = res.data;
|
|
82
|
+
// Prefer the human-facing message; fall back to the machine code.
|
|
83
|
+
detail = j.message || j.detail || j.error || detail;
|
|
84
|
+
code = j.error || null;
|
|
85
|
+
} catch (_) { /* keep default */ }
|
|
86
|
+
const err = new Error(detail);
|
|
87
|
+
err.status = res.status;
|
|
88
|
+
err.code = code;
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
return res.data;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- shared render helpers (C10) ----------------------------------------
|
|
95
|
+
|
|
96
|
+
// emptyRow — the placeholder/error <tr> every ledger table renders when it has
|
|
97
|
+
// nothing (or failed) to show. `message` is plain copy (escaped defensively
|
|
98
|
+
// even though current callers pass static literals).
|
|
99
|
+
function emptyRow(colspan, message) {
|
|
100
|
+
return `<tr><td colspan="${colspan}" class="ledger__empty">${escapeHtml(message)}</td></tr>`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// signClasses — the up/down/flat BEM modifier for a signed number. `prefix` is
|
|
104
|
+
// the block, e.g. signClasses(delta, 'karma-feed__delta') → 'karma-feed__delta--up'.
|
|
105
|
+
// The +/- TEXT differs per feed (fmtNum vs raw int vs '±0'), so callers format
|
|
106
|
+
// the displayed value themselves.
|
|
107
|
+
function signClasses(n, prefix) {
|
|
108
|
+
const v = Number(n) || 0;
|
|
109
|
+
return `${prefix}--${v > 0 ? 'up' : v < 0 ? 'down' : 'flat'}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// renderPager — the works-wrought / roster twin pager. Clamps state.page into
|
|
113
|
+
// range (mutating it), hides the pager when there is ≤1 page, writes the
|
|
114
|
+
// "start–end of total" range label, and toggles prev/next. Returns { start } so
|
|
115
|
+
// the caller can slice. NOTE: the leaderboard pager deliberately does NOT use
|
|
116
|
+
// this — it never clamps and keeps its range/buttons always visible.
|
|
117
|
+
function renderPager(state, total, pageSize, { pagerEl, rangeEl, prevBtn, nextBtn }) {
|
|
118
|
+
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
|
119
|
+
if (state.page >= pageCount) state.page = pageCount - 1;
|
|
120
|
+
if (state.page < 0) state.page = 0;
|
|
121
|
+
const start = state.page * pageSize;
|
|
122
|
+
if (pagerEl) {
|
|
123
|
+
if (pageCount > 1) {
|
|
124
|
+
pagerEl.hidden = false;
|
|
125
|
+
if (rangeEl) {
|
|
126
|
+
const end = Math.min(start + pageSize, total);
|
|
127
|
+
rangeEl.textContent = `${start + 1}–${end} of ${total}`;
|
|
128
|
+
}
|
|
129
|
+
if (prevBtn) prevBtn.disabled = state.page === 0;
|
|
130
|
+
if (nextBtn) nextBtn.disabled = state.page >= pageCount - 1;
|
|
131
|
+
} else {
|
|
132
|
+
pagerEl.hidden = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { start };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- repo info (the clone URL the welcome walkthrough hands a newcomer) ---
|
|
139
|
+
//
|
|
140
|
+
// The /builder-* commands live INSIDE the repo (.claude/skills/, shelling out
|
|
141
|
+
// to scripts/gds/*.js). Setup now runs on a hosted dev box (#777), so the
|
|
142
|
+
// newcomer never clones a repo locally — the old repo-clone walkthrough (and
|
|
143
|
+
// the /public/repo-info fetch that fed it) was removed with the setup-first
|
|
144
|
+
// redesign. The /public/repo-info surface still exists for other callers.
|
|
145
|
+
|
|
146
|
+
// ---- rank badge (V3.R16 #240) -------------------------------------------
|
|
147
|
+
// Three live ranks per ADR 0018: archon > metic > xenos. Missing rank
|
|
148
|
+
// renders an em-dash plaque so the layout never breaks.
|
|
149
|
+
|
|
150
|
+
const RANK_ORDER = { archon: 4, metic: 3, thetes: 2, xenos: 1 };
|
|
151
|
+
|
|
152
|
+
function rankWeight(rank) {
|
|
153
|
+
return RANK_ORDER[rank] ?? -1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Coerce any rank that isn't one of the four live ranks (archon/metic/thetes/
|
|
157
|
+
// xenos — #692 / ADR 0034) to the entry rank. Reserved values still on the
|
|
158
|
+
// citizen/divine ladder would otherwise render as a dashed "unknown" plaque
|
|
159
|
+
// while /builders/ranks shows them as xenos. Applied to the
|
|
160
|
+
// builder-facing standing surfaces — the welcome panel, the profile Standing
|
|
161
|
+
// badge, and the public Roll — so they agree. NOT applied to the Archon-only
|
|
162
|
+
// roster below, which intentionally shows the true stored rank so an Archon
|
|
163
|
+
// can spot and fix a stray value. Mirrors the RANK_LADDER coercion in ranks.js.
|
|
164
|
+
function normalizeRank(rank) {
|
|
165
|
+
return RANK_ORDER[rank] ? rank : 'xenos';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Short one-line summary of what each rank unlocks. Kept terse — the hall's
|
|
169
|
+
// job is the daily action surface, not the full permissions reference (that
|
|
170
|
+
// lives at /builders/ranks). Sourced from docs/canonical-permissions.md.
|
|
171
|
+
// Used by the rank-badge hover tooltip (opts.tip) and mirrored in ranks.js.
|
|
172
|
+
const RANK_UNLOCKS = {
|
|
173
|
+
xenos: [
|
|
174
|
+
'Read every public page.',
|
|
175
|
+
'Claim and ship tasks an Archon has flagged newcomer-friendly.',
|
|
176
|
+
`Earn ${curLabel()} for shipped work and karma for ratified ideas.`,
|
|
177
|
+
],
|
|
178
|
+
thetes: [
|
|
179
|
+
'Claim and ship any task in the general queue — no longer only newcomer-friendly work.',
|
|
180
|
+
`Keep earning ${curLabel()} and karma for every clean ship.`,
|
|
181
|
+
'No triage, blockers, planning, or task-shaping yet — those levers open at Metic.',
|
|
182
|
+
],
|
|
183
|
+
metic: [
|
|
184
|
+
'Claim and ship any task in the ready queue.',
|
|
185
|
+
'Triage the idea inbox; resolve blockers; run planning sessions.',
|
|
186
|
+
'Propose new version criteria for Archon ratification.',
|
|
187
|
+
],
|
|
188
|
+
archon: [
|
|
189
|
+
'Create, promote, and edit tasks; reshape the dependency graph.',
|
|
190
|
+
'Grant or revoke ranks; close versions; confirm ships past the grader.',
|
|
191
|
+
'Adjudicate vulnerability reports and override requests.',
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Monotonic counter for unique tooltip ids (aria-describedby). Re-renders
|
|
196
|
+
// replace innerHTML so duplicates would otherwise collide.
|
|
197
|
+
let rankTipSeq = 0;
|
|
198
|
+
|
|
199
|
+
function renderRankBadge(rank, opts = {}) {
|
|
200
|
+
const label = escapeHtml(rankLabel(rank));
|
|
201
|
+
const ariaLabel = rank ? `Rank: ${rankLabel(rank)}` : 'Rank not yet recorded';
|
|
202
|
+
const variant = RANK_ORDER[rank] ? `rank-badge--${rank}` : 'rank-badge--unknown';
|
|
203
|
+
|
|
204
|
+
// Plain plaque (default — used in tables, roster, leaderboard).
|
|
205
|
+
if (!opts.tip || !RANK_UNLOCKS[rank]) {
|
|
206
|
+
const title = opts.title ? ` title="${escapeHtml(opts.title)}"` : '';
|
|
207
|
+
return `<span class="rank-badge ${variant}" aria-label="${escapeHtml(ariaLabel)}"${title}>${label}</span>`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Tooltip variant — a hover/focus popover lists what this rank unlocks,
|
|
211
|
+
// and a transparent "stretched link" overlay makes the whole plaque
|
|
212
|
+
// navigate to the full ladder. We keep the badge a <span> (not an <a>) on
|
|
213
|
+
// purpose: the hall's global a:hover rule repaints links terracotta, which
|
|
214
|
+
// would otherwise bleed into the plaque AND the tip text. The overlay
|
|
215
|
+
// anchor carries the navigation + keyboard focus without touching colour.
|
|
216
|
+
// NB: this markup contains a <ul>, which is NOT valid inside a <p>. If a
|
|
217
|
+
// caller drops this tipped badge into a <p>, the HTML parser auto-closes
|
|
218
|
+
// the <p> at the <ul> and hoists the list out of .rank-tip — the unlocks
|
|
219
|
+
// then render as a visible block instead of a hover popover (#530). Render
|
|
220
|
+
// it inside a <div>/inline container, never a <p>.
|
|
221
|
+
const tipId = `rank-tip-${rank}-${rankTipSeq++}`;
|
|
222
|
+
const items = RANK_UNLOCKS[rank].map((u) => `<li>${escapeHtml(u)}</li>`).join('');
|
|
223
|
+
return `<span class="rank-badge-wrap">` +
|
|
224
|
+
`<span class="rank-badge ${variant} rank-badge--tipped" aria-hidden="true">${label}</span>` +
|
|
225
|
+
`<a class="rank-badge-stretch" href="/ranks" ` +
|
|
226
|
+
`aria-describedby="${tipId}" ` +
|
|
227
|
+
`aria-label="${escapeHtml(ariaLabel)} — see all standings"></a>` +
|
|
228
|
+
`<span class="rank-tip" id="${tipId}" role="tooltip">` +
|
|
229
|
+
`<span class="rank-tip__label">What ${label} unlocks</span>` +
|
|
230
|
+
`<ul class="rank-tip__list">${items}</ul>` +
|
|
231
|
+
`<span class="rank-tip__more">Walk the full ladder →</span>` +
|
|
232
|
+
`</span>` +
|
|
233
|
+
`</span>`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- profile -------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
// Cached karma payload from the most recent /me call. Lets non-karma
|
|
239
|
+
// re-renders (e.g. saving a discipline preference, which PATCHes the
|
|
240
|
+
// builder row but not karma) keep showing the same karma badge instead
|
|
241
|
+
// of flickering to zero. Updated only by loadAll().
|
|
242
|
+
let lastKarmaState = null;
|
|
243
|
+
// V3.R84 (#302), criterion C3: cache the most recent /me `cost` status so the
|
|
244
|
+
// Standing-card budget banner survives re-renders triggered by responses that
|
|
245
|
+
// don't carry cost (e.g. the disciplines PATCH).
|
|
246
|
+
let lastCostState = null;
|
|
247
|
+
// task 1013 / ADR 0073: cache the most recent /me `needs` list so the
|
|
248
|
+
// Standing-card "Action needed" banner survives re-renders that don't carry
|
|
249
|
+
// needs (same pattern as lastCostState).
|
|
250
|
+
let lastNeedsState = null;
|
|
251
|
+
|
|
252
|
+
// Cached /me payload from the most recent loadAll() call. Used by the
|
|
253
|
+
// welcome-chip reopen path (#408) so we can re-render the welcome panel
|
|
254
|
+
// from cached state without a fresh /me round-trip. Distinct from
|
|
255
|
+
// lastKarmaState: that one only stashes karma; this stashes the whole
|
|
256
|
+
// payload so renderWelcome's builder + onboarding args are available.
|
|
257
|
+
let lastMeState = null;
|
|
258
|
+
|
|
259
|
+
// Instance repo coords for the "get the code" clone block. The public instance
|
|
260
|
+
// manifest (GET /api/bongos/instance) is the channel for cross-instance repo coords
|
|
261
|
+
// — clientBranding deliberately omits `repo`, so window.__BRANDING__ can't carry
|
|
262
|
+
// it. Cached; the welcome re-renders when it lands so a fresh instance shows ITS
|
|
263
|
+
// OWN repo, never a hardcoded one (ADR 0062 §3, task 1705).
|
|
264
|
+
let instanceRepo = null;
|
|
265
|
+
async function loadInstanceRepo() {
|
|
266
|
+
try {
|
|
267
|
+
const inst = await getJSON(`${API}/instance`, { softAuth: true });
|
|
268
|
+
instanceRepo = inst && inst.repo && !inst.repo.error ? inst.repo : null;
|
|
269
|
+
if (instanceRepo && lastMeState) renderWelcome(lastMeState.builder, lastMeState.onboarding || null);
|
|
270
|
+
} catch (_) { /* best-effort — the clone block falls back to generic guidance */ }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---- hall section nav (#408) --------------------------------------------
|
|
274
|
+
//
|
|
275
|
+
// Single source of truth for what appears in the sticky strip at top of
|
|
276
|
+
// the page. Each entry binds a section's DOM id to a short Cinzel label
|
|
277
|
+
// and any rank/visibility gates. Order here is render order, not chip
|
|
278
|
+
// order — but rendering happens after all sections are in the DOM, so
|
|
279
|
+
// the two end up matching.
|
|
280
|
+
//
|
|
281
|
+
// Filter rules at render time:
|
|
282
|
+
// archonOnly: true → skip unless /me returns rank=archon.
|
|
283
|
+
// welcomeChip:true → ALWAYS shown pre-graduation (even when the
|
|
284
|
+
// panel is dismissed) so the builder can re-open
|
|
285
|
+
// it. Hidden post-graduation just like the panel.
|
|
286
|
+
// default → skip if the section's element is [hidden].
|
|
287
|
+
//
|
|
288
|
+
// Adding a new section: add a row here AND give the <section> a stable
|
|
289
|
+
// id. The nav inherits ranking from /me automatically.
|
|
290
|
+
|
|
291
|
+
// Hall section order (#726). The hall is a citizen's PERSONAL surface:
|
|
292
|
+
// Standing, Honours, Karma (with acclaim folded under it), Works, then
|
|
293
|
+
// Marks/Curve/Bounty/Roll. The Archon monitoring sections (Watch/Gate/Roster)
|
|
294
|
+
// moved to the dedicated /builders/watch page; the per-skill model picker
|
|
295
|
+
// (Voices) + craft preferences moved to /builders/settings. Both are linked
|
|
296
|
+
// from the header gated-nav, not the section strip.
|
|
297
|
+
//
|
|
298
|
+
// BV1.R75 (ADR 0062 §4): the SECTIONS array + the inline <section> markup were
|
|
299
|
+
// the two hardcoded copies of "what the hall shows". Both are gone. The single
|
|
300
|
+
// source of truth is now the hall-widget registry (window.OTB, dom-utils.js):
|
|
301
|
+
// hall-widgets.js registers the core widgets above (loads before this script),
|
|
302
|
+
// a module's client script registers its own (gated by loader.js + moduleOn).
|
|
303
|
+
// SECTIONS() derives the nav rows from the registry, preserving order + the
|
|
304
|
+
// per-widget gates (welcomeChip / archonOnly) carried on the registry entry.
|
|
305
|
+
const SECTIONS = () => window.OTB.hallWidgets().map((wgt) => ({
|
|
306
|
+
id: wgt.id,
|
|
307
|
+
label: wgt.label,
|
|
308
|
+
view: wgt.view || 'home',
|
|
309
|
+
welcomeChip: !!wgt.welcomeChip,
|
|
310
|
+
archonOnly: !!wgt.archonOnly,
|
|
311
|
+
}));
|
|
312
|
+
|
|
313
|
+
// task 1723: which container a widget mounts into. A widget may declare
|
|
314
|
+
// `zone` (a fine-grained Home slot) or `view` (home/standing/achievements/
|
|
315
|
+
// leaderboard); both default to the Home main zone so pre-redesign module
|
|
316
|
+
// widgets still land somewhere visible. Every container lives INSIDE <main>,
|
|
317
|
+
// so the `main .scroll` sweeps (newcomer gate, landing hide-all) keep working.
|
|
318
|
+
const VIEW_CONTAINERS = {
|
|
319
|
+
home: 'zone-home-main',
|
|
320
|
+
standing: 'zone-standing',
|
|
321
|
+
achievements: 'zone-achievements',
|
|
322
|
+
leaderboard: 'zone-leaderboard',
|
|
323
|
+
};
|
|
324
|
+
function widgetHost(wgt) {
|
|
325
|
+
const zone = wgt.zone ? document.getElementById(`zone-${wgt.zone}`) : null;
|
|
326
|
+
if (zone) return zone;
|
|
327
|
+
const viewId = VIEW_CONTAINERS[wgt.view || 'home'] || VIEW_CONTAINERS.home;
|
|
328
|
+
return document.getElementById(viewId) || document.querySelector('main');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// mountHallWidgets — stamp every registered widget's <section> into its view
|
|
332
|
+
// container (falling back to <main>), in registry order, then render its
|
|
333
|
+
// skeleton via mount(host). Idempotent: a second call (defensive) re-uses an
|
|
334
|
+
// existing <section>. Runs once at bootstrap BEFORE wireEvents() so the static
|
|
335
|
+
// element ids the data-render + event-wiring code expects (#welcome-minimize,
|
|
336
|
+
// #recent-ships, #lb-prev, …) are present. Each widget mount is
|
|
337
|
+
// failure-isolated so one bad widget can't blank the hall.
|
|
338
|
+
function mountHallWidgets() {
|
|
339
|
+
const main = document.querySelector('main');
|
|
340
|
+
if (!main || typeof window.OTB.hallWidgets !== 'function') return;
|
|
341
|
+
for (const wgt of window.OTB.hallWidgets()) {
|
|
342
|
+
try {
|
|
343
|
+
let section = document.getElementById(wgt.id);
|
|
344
|
+
if (!section) {
|
|
345
|
+
section = document.createElement('section');
|
|
346
|
+
section.id = wgt.id;
|
|
347
|
+
(widgetHost(wgt) || main).appendChild(section);
|
|
348
|
+
}
|
|
349
|
+
section.className = wgt.sectionClass || 'scroll';
|
|
350
|
+
if (wgt.labelledby) section.setAttribute('aria-labelledby', wgt.labelledby);
|
|
351
|
+
// hiddenByDefault widgets (welcome/onboarding/karma/acclaim/curve) start
|
|
352
|
+
// hidden exactly as the old inline markup did; their own render code
|
|
353
|
+
// un-hides them when there's something to show.
|
|
354
|
+
if (wgt.hiddenByDefault) section.hidden = true;
|
|
355
|
+
wgt.mount(section);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
console.error(`[builders] hall widget "${wgt && wgt.id}" failed to mount`, err);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// brand-apply.js's currency-label pass ran at DOMContentLoaded, BEFORE these
|
|
361
|
+
// skeletons existed — re-run it over the mounted widgets so [data-currency-
|
|
362
|
+
// label]/[data-currency-tmpl] cells show the instance's unit, not the fallback.
|
|
363
|
+
window.OTBBranding?.applyCurrencyLabels?.(main);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// The IntersectionObserver instance survives across re-renders (it's
|
|
367
|
+
// disconnected and re-created when nav contents change). Stored on
|
|
368
|
+
// module scope so nav re-renders can dispose of the previous one.
|
|
369
|
+
let navObserver = null;
|
|
370
|
+
|
|
371
|
+
// task 1723: the old sticky chip strip is gone. renderNav now settles the
|
|
372
|
+
// three nav surfaces the shell owns or hosts:
|
|
373
|
+
// 1. the sidebar's gated entries, via window.OTBShell.applyAccess (cosmetic —
|
|
374
|
+
// the server enforces every page + endpoint regardless, ADR 0016);
|
|
375
|
+
// 2. the Home re-open affordance for a dismissed welcome banner (the old
|
|
376
|
+
// welcome chip's only job);
|
|
377
|
+
// 3. the Standing view's in-page section rail (#standing-rail), rebuilt from
|
|
378
|
+
// the registry's standing widgets that are currently shown.
|
|
379
|
+
// Keeps its (builder, onboarding) signature — all call sites pass them.
|
|
380
|
+
function renderNav(builder, onboarding) {
|
|
381
|
+
const isArchon = (builder?.rank || '').toLowerCase() === 'archon';
|
|
382
|
+
const graduated = !!(onboarding && onboarding.graduated);
|
|
383
|
+
// #833: for a newcomer the Work entry surfaces only once the "ship three"
|
|
384
|
+
// stage is current — before then the path reads one action at a time.
|
|
385
|
+
const stages = Array.isArray(onboarding?.stages) ? onboarding.stages : [];
|
|
386
|
+
const shipStepCurrent = stages.some((s) => s.id === 'ship_three' && s.is_current && !s.cleared);
|
|
387
|
+
window.OTBShell?.applyAccess({
|
|
388
|
+
authed: true,
|
|
389
|
+
rank: normalizeRank(builder?.rank),
|
|
390
|
+
graduated,
|
|
391
|
+
showWork: shipStepCurrent,
|
|
392
|
+
builder,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Re-open affordance: pre-graduation, when the welcome banner is dismissed
|
|
396
|
+
// (hidden), a small link brings it back (reopenWelcomeFromNav).
|
|
397
|
+
const reopen = document.getElementById('welcome-reopen');
|
|
398
|
+
if (reopen) {
|
|
399
|
+
const welcomeEl = document.getElementById('welcome-scroll');
|
|
400
|
+
reopen.hidden = !(welcomeEl && welcomeEl.hidden && !graduated);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Standing section rail: one link per visible standing panel.
|
|
404
|
+
const rail = document.getElementById('standing-rail');
|
|
405
|
+
if (rail) {
|
|
406
|
+
const items = SECTIONS().filter((s) => {
|
|
407
|
+
if (s.view !== 'standing') return false;
|
|
408
|
+
if (s.archonOnly && !isArchon) return false;
|
|
409
|
+
const el = document.getElementById(s.id);
|
|
410
|
+
return !!el && !el.hidden;
|
|
411
|
+
});
|
|
412
|
+
rail.innerHTML = items.map((s) =>
|
|
413
|
+
`<a class="rail-link" href="#${escapeHtml(s.id)}" data-section="${escapeHtml(s.id)}">${escapeHtml(s.label)}</a>`
|
|
414
|
+
).join('');
|
|
415
|
+
rail.hidden = items.length < 2;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Rebuild the observer to track whichever sections are currently in
|
|
419
|
+
// the rail. Doing this on each renderNav call covers both initial load
|
|
420
|
+
// and post-dismiss/re-open re-renders.
|
|
421
|
+
setupNavObserver();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// #748: newcomer gate. A Xenos sees a deliberately spare hall — their
|
|
425
|
+
// onboarding path and their Standing card — until they graduate to Thetes.
|
|
426
|
+
// Everything else (Honours, Karma, Acclaim, Works, Claim, Marks, Bounty,
|
|
427
|
+
// Roll) stays hidden so a fresh arrival isn't drowned before they've
|
|
428
|
+
// shipped their first works. The Settings link is the exception (task
|
|
429
|
+
// 1291): a newcomer needs it for first-time setup — CLI re-auth (Settings
|
|
430
|
+
// → CLI Access) and choosing their craft both live there. #766 also hides the
|
|
431
|
+
// citizen's primer + the "how the system works" maps — a newcomer's path is
|
|
432
|
+
// action-only. #770: the Work Board link IS shown to a Xenos — a newcomer
|
|
433
|
+
// needs to find the work they can claim, so the path "find work → claim →
|
|
434
|
+
// ship three" stays one click away; the rank ladder also stays so they can
|
|
435
|
+
// see what they're climbing toward. This is cosmetic UX only — every data
|
|
436
|
+
// endpoint stays server-rank-gated regardless (the Work Board's claimable
|
|
437
|
+
// feed only returns work accessible to the caller's rank), and a Xenos who
|
|
438
|
+
// types /primer or /diagrams directly is bounced to /builders/not-ready by
|
|
439
|
+
// the server (#770). Set early in loadAll so the async karma/acclaim feeds
|
|
440
|
+
// (which un-hide their own scroll on data) respect it; the section-hiding
|
|
441
|
+
// sweep then runs before renderNav so the chip strip matches.
|
|
442
|
+
const NEWCOMER_VISIBLE_SECTIONS = new Set([
|
|
443
|
+
'welcome-scroll', 'onboarding-scroll', 'profile-scroll',
|
|
444
|
+
]);
|
|
445
|
+
let newcomerGated = false;
|
|
446
|
+
|
|
447
|
+
function applyNewcomerGate() {
|
|
448
|
+
if (!newcomerGated) return;
|
|
449
|
+
document.querySelectorAll('main .scroll').forEach((el) => {
|
|
450
|
+
if (!NEWCOMER_VISIBLE_SECTIONS.has(el.id)) el.hidden = true;
|
|
451
|
+
});
|
|
452
|
+
// task 1723: the newcomer LINK gating (primer/diagrams/atlas hidden; Work
|
|
453
|
+
// only once the "ship three" stage is current, #833; Settings always
|
|
454
|
+
// visible, task 1291) moved to the shell sidebar — renderNav reports rank +
|
|
455
|
+
// showWork to OTBShell.applyAccess and shell.js resolves the 'member'/'work'
|
|
456
|
+
// gates. This function keeps only the widget-section sweep.
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function setupNavObserver() {
|
|
460
|
+
if (navObserver) {
|
|
461
|
+
navObserver.disconnect();
|
|
462
|
+
navObserver = null;
|
|
463
|
+
}
|
|
464
|
+
const list = document.getElementById('standing-rail');
|
|
465
|
+
if (!list) return;
|
|
466
|
+
|
|
467
|
+
const chipsBySection = new Map();
|
|
468
|
+
list.querySelectorAll('a[data-section]').forEach((a) => {
|
|
469
|
+
chipsBySection.set(a.dataset.section, a);
|
|
470
|
+
});
|
|
471
|
+
if (chipsBySection.size === 0) return;
|
|
472
|
+
|
|
473
|
+
const sections = Array.from(chipsBySection.keys())
|
|
474
|
+
.map((id) => document.getElementById(id))
|
|
475
|
+
.filter((el) => el && !el.hidden);
|
|
476
|
+
|
|
477
|
+
if (sections.length === 0) return;
|
|
478
|
+
|
|
479
|
+
// rootMargin trims the active band to roughly the upper third of the
|
|
480
|
+
// viewport (top 40% ignored, bottom 55% ignored — a ~5% horizontal
|
|
481
|
+
// strip about a third of the way down the page). The active flip
|
|
482
|
+
// happens when a section headline crosses that band, not when the
|
|
483
|
+
// section first peeks in. Matches the rationale in the task spec.
|
|
484
|
+
navObserver = new IntersectionObserver((entries) => {
|
|
485
|
+
for (const entry of entries) {
|
|
486
|
+
if (entry.isIntersecting) {
|
|
487
|
+
const id = entry.target.id;
|
|
488
|
+
chipsBySection.forEach((chip, sectionId) => {
|
|
489
|
+
chip.classList.toggle('rail-link--active', sectionId === id);
|
|
490
|
+
});
|
|
491
|
+
// Auto-scroll the active chip into view on mobile so the user
|
|
492
|
+
// can see what section they're in without having to scroll the
|
|
493
|
+
// nav strip themselves.
|
|
494
|
+
const active = chipsBySection.get(id);
|
|
495
|
+
if (active && window.matchMedia('(max-width: 640px)').matches) {
|
|
496
|
+
active.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
|
497
|
+
}
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}, {
|
|
502
|
+
rootMargin: '-40% 0px -55% 0px',
|
|
503
|
+
threshold: 0,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
sections.forEach((sec) => navObserver.observe(sec));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function reopenWelcomeFromNav(e) {
|
|
510
|
+
e.preventDefault();
|
|
511
|
+
const id = window.__buildersSelfId;
|
|
512
|
+
if (!id || !lastMeState) return;
|
|
513
|
+
try {
|
|
514
|
+
window.localStorage.removeItem(welcomeDismissalKey(id));
|
|
515
|
+
} catch (_) { /* private mode — re-open in-memory only */ }
|
|
516
|
+
// Re-render welcome from cached /me state and refresh the nav so the
|
|
517
|
+
// chip drops its "dismissed" styling.
|
|
518
|
+
renderWelcome(lastMeState.builder, lastMeState.onboarding || null);
|
|
519
|
+
renderNav(lastMeState.builder, lastMeState.onboarding || null);
|
|
520
|
+
// Now scroll to the (newly-unhidden) welcome panel.
|
|
521
|
+
const target = document.getElementById('welcome-scroll');
|
|
522
|
+
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function renderLanding() {
|
|
526
|
+
// task 1723: collapse the shell nav to the signed-out minimum (Home + a
|
|
527
|
+
// Sign-in button). Cosmetic — the server gates every page regardless.
|
|
528
|
+
window.OTBShell?.applyAccess({ authed: false });
|
|
529
|
+
|
|
530
|
+
const welcome = $('#welcome-line');
|
|
531
|
+
if (welcome) welcome.textContent = '';
|
|
532
|
+
|
|
533
|
+
const landing = $('#landing-root');
|
|
534
|
+
if (landing) {
|
|
535
|
+
landing.hidden = false;
|
|
536
|
+
landing.innerHTML = `
|
|
537
|
+
<div class="scroll landing">
|
|
538
|
+
<h2 class="scroll__h">Welcome</h2>
|
|
539
|
+
<p class="landing__lede">
|
|
540
|
+
This is the builders' hub for ${brandWorld()} — where builders claim
|
|
541
|
+
work, earn ${curLabel()}, and track their standing. Access is
|
|
542
|
+
invite-based: an Archon approves each new builder. To begin, request
|
|
543
|
+
access below.
|
|
544
|
+
</p>
|
|
545
|
+
|
|
546
|
+
<div class="landing__request" id="landing-request">
|
|
547
|
+
<h3 class="landing__request-h">New here — request access</h3>
|
|
548
|
+
<p class="landing__request-lede">
|
|
549
|
+
Leave your GitHub username and an Archon will let you in. No coding
|
|
550
|
+
needed to start — once you're approved, sign in with GitHub and a
|
|
551
|
+
step-by-step guide takes you the rest of the way.
|
|
552
|
+
</p>
|
|
553
|
+
<form class="landing__form" id="access-request-form" novalidate>
|
|
554
|
+
<label class="landing__field">
|
|
555
|
+
<span>GitHub username <span class="landing__req-mark" aria-hidden="true">*</span></span>
|
|
556
|
+
<input type="text" id="ar-login" name="github_login" autocomplete="username"
|
|
557
|
+
placeholder="e.g. octocat" maxlength="40" required>
|
|
558
|
+
</label>
|
|
559
|
+
<label class="landing__field">
|
|
560
|
+
<span>Your name <span class="landing__opt">(optional)</span></span>
|
|
561
|
+
<input type="text" id="ar-name" name="display_name" autocomplete="name"
|
|
562
|
+
placeholder="So the Archon knows who you are" maxlength="100">
|
|
563
|
+
</label>
|
|
564
|
+
<label class="landing__field">
|
|
565
|
+
<span>A word on why <span class="landing__opt">(optional)</span></span>
|
|
566
|
+
<input type="text" id="ar-note" name="note"
|
|
567
|
+
placeholder="How you got here, what you'd build" maxlength="500">
|
|
568
|
+
</label>
|
|
569
|
+
<p class="landing__form-hint">
|
|
570
|
+
No GitHub account yet? Make a free one at
|
|
571
|
+
<a href="https://github.com/signup" target="_blank" rel="noopener noreferrer">github.com</a>
|
|
572
|
+
first — your username is all we need.
|
|
573
|
+
</p>
|
|
574
|
+
<button type="submit" class="landing__btn landing__btn--request" id="ar-submit">Request access</button>
|
|
575
|
+
<p class="landing__request-status" id="ar-status" aria-live="polite"></p>
|
|
576
|
+
</form>
|
|
577
|
+
</div>
|
|
578
|
+
|
|
579
|
+
<p class="landing__signin-note">
|
|
580
|
+
Already a member? <a href="${API}/auth/web/start">Sign in with GitHub</a>.
|
|
581
|
+
We store only your GitHub login, avatar, and the work you ship.
|
|
582
|
+
</p>
|
|
583
|
+
</div>
|
|
584
|
+
`;
|
|
585
|
+
wireAccessRequestForm();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Hide every widget section — nothing to show until the visitor signs in.
|
|
589
|
+
document.querySelectorAll('main .scroll').forEach((el) => {
|
|
590
|
+
if (!landing || !landing.contains(el)) el.hidden = true;
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Wires the public landing's access-request form. Submits to the one public
|
|
595
|
+
// GDS write (POST /access-requests). On success the form collapses into a
|
|
596
|
+
// confirmation so the stranger knows the Archon has been pinged.
|
|
597
|
+
function wireAccessRequestForm() {
|
|
598
|
+
const form = $('#access-request-form');
|
|
599
|
+
if (!form) return;
|
|
600
|
+
form.addEventListener('submit', async (e) => {
|
|
601
|
+
e.preventDefault();
|
|
602
|
+
const loginEl = $('#ar-login');
|
|
603
|
+
const nameEl = $('#ar-name');
|
|
604
|
+
const noteEl = $('#ar-note');
|
|
605
|
+
const submit = $('#ar-submit');
|
|
606
|
+
const status = $('#ar-status');
|
|
607
|
+
const login = (loginEl?.value || '').trim().replace(/^@/, '');
|
|
608
|
+
if (!login) {
|
|
609
|
+
if (status) { status.dataset.kind = 'err'; status.textContent = 'A GitHub username is needed.'; }
|
|
610
|
+
if (loginEl) loginEl.focus();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (submit) { submit.disabled = true; submit.textContent = 'Sending…'; }
|
|
614
|
+
if (status) { status.dataset.kind = ''; status.textContent = ''; }
|
|
615
|
+
try {
|
|
616
|
+
const data = await postJSON(`${API}/access-requests`, {
|
|
617
|
+
github_login: login,
|
|
618
|
+
display_name: (nameEl?.value || '').trim() || null,
|
|
619
|
+
note: (noteEl?.value || '').trim() || null,
|
|
620
|
+
});
|
|
621
|
+
const req = $('#landing-request');
|
|
622
|
+
if (req) {
|
|
623
|
+
req.innerHTML = `
|
|
624
|
+
<h3 class="landing__request-h">Your request is sent ✓</h3>
|
|
625
|
+
<p class="landing__request-lede">
|
|
626
|
+
${data && data.already_pending
|
|
627
|
+
? 'You were already in the queue — an Archon will approve you. Once approved, sign in with GitHub to get started.'
|
|
628
|
+
: 'An Archon has been notified and will approve you. Once approved, sign in with GitHub to get started.'}
|
|
629
|
+
</p>
|
|
630
|
+
<p class="landing__request-lede landing__request-lede--soft">
|
|
631
|
+
While you wait: install the Claude desktop app at
|
|
632
|
+
<a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">claude.com/download</a>.
|
|
633
|
+
The full step-by-step guide appears here once you're in.
|
|
634
|
+
</p>`;
|
|
635
|
+
}
|
|
636
|
+
} catch (err) {
|
|
637
|
+
// Already known to the city — they don't need a request, they need to
|
|
638
|
+
// sign in. Both the "already a builder" and "already approved" cases
|
|
639
|
+
// collapse the form into a sign-in prompt rather than a red error.
|
|
640
|
+
if (err && (err.code === 'already_member' || err.code === 'already_approved')) {
|
|
641
|
+
const req = $('#landing-request');
|
|
642
|
+
if (req) {
|
|
643
|
+
req.innerHTML = `
|
|
644
|
+
<h3 class="landing__request-h">You already have access ✓</h3>
|
|
645
|
+
<p class="landing__request-lede">${escapeHtml(err.message || 'You already have access.')}</p>
|
|
646
|
+
<p class="landing__cta">
|
|
647
|
+
<a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a>
|
|
648
|
+
</p>`;
|
|
649
|
+
}
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (submit) { submit.disabled = false; submit.textContent = 'Request access'; }
|
|
653
|
+
if (status) {
|
|
654
|
+
status.dataset.kind = 'err';
|
|
655
|
+
status.textContent = (err && err.status === 400)
|
|
656
|
+
? 'That does not look like a GitHub username — check it and try again.'
|
|
657
|
+
: 'Could not send just now. Try again in a moment.';
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Reads the module-level lastKarmaState / lastCostState directly — every caller
|
|
664
|
+
// passed those same globals back in, so the params were redundant plumbing (C10).
|
|
665
|
+
function renderProfile(builder) {
|
|
666
|
+
const karma = lastKarmaState;
|
|
667
|
+
const cost = lastCostState;
|
|
668
|
+
const needs = lastNeedsState;
|
|
669
|
+
const avatar = builder.avatar_url
|
|
670
|
+
? `<img class="profile__avatar" src="${escapeHtml(builder.avatar_url)}" alt="">`
|
|
671
|
+
: `<div class="profile__avatar" aria-hidden="true"></div>`;
|
|
672
|
+
|
|
673
|
+
const prefs = Array.isArray(builder.preferred_disciplines) ? builder.preferred_disciplines : [];
|
|
674
|
+
|
|
675
|
+
const rankBadge = builder.rank
|
|
676
|
+
? renderRankBadge(normalizeRank(builder.rank), { tip: true })
|
|
677
|
+
: renderRankBadge(builder.rank, { title: 'No rank recorded yet' });
|
|
678
|
+
|
|
679
|
+
// #757: this builder's place on the Roll. Null until the leaderboard rows
|
|
680
|
+
// load (they're fetched async after the Standing card's first paint; the
|
|
681
|
+
// card re-renders once they arrive) — when null the stat simply omits.
|
|
682
|
+
const rollPlace = leaderboardPlace(builder.github_login);
|
|
683
|
+
|
|
684
|
+
// V3.R26 (#246): karma badge + 30-day delta. Drachmae and karma are two
|
|
685
|
+
// separate counters — drachmae = work shipped, karma = good citizenship
|
|
686
|
+
// (peer recognition + Archon ratifications). The tooltip on each badge
|
|
687
|
+
// spells the distinction out so a new builder can tell them apart.
|
|
688
|
+
const karmaTotal = Number(builder.karma) || 0;
|
|
689
|
+
const karma30d = karma ? Number(karma.delta_30d) || 0 : 0;
|
|
690
|
+
const karmaTrendText = karma30d > 0
|
|
691
|
+
? `+${fmtNum(karma30d)} in 30d`
|
|
692
|
+
: (karma30d < 0 ? `${fmtNum(karma30d)} in 30d` : '±0 in 30d');
|
|
693
|
+
const karmaTrend = `<span class="karma-badge__trend ${signClasses(karma30d, 'karma-badge__trend')}">${karmaTrendText}</span>`;
|
|
694
|
+
const karmaTooltip = `Karma = peer recognition and approvals. ${curLabel(true)} = work shipped. The two counters move independently — see the karma feed for the audit trail.`;
|
|
695
|
+
const drachmaeTooltip = `${curLabel(true)} = total payout from shipped work. Karma is a separate counter for peer recognition and approvals.`;
|
|
696
|
+
|
|
697
|
+
// V3.R84 (#302), criterion C3: monthly-budget banner. `warn` (>=80%) is a
|
|
698
|
+
// heads-up; `over` (>=100%) means cost-incurring actions (ship, art pipeline)
|
|
699
|
+
// are blocked until an Archon raises the budget. Nothing renders under 80%.
|
|
700
|
+
const budgetBanner = (cost && (cost.level === 'warn' || cost.level === 'over'))
|
|
701
|
+
? `<div class="profile__budget profile__budget--${cost.level}" role="status">
|
|
702
|
+
<strong>${cost.level === 'over' ? 'Monthly budget exceeded' : 'Approaching monthly budget'}</strong>
|
|
703
|
+
— $${escapeHtml(String(cost.spend_mtd_usd))} of $${escapeHtml(String(cost.budget_mtd_usd))} this month (${escapeHtml(String(cost.pct_used))}%).
|
|
704
|
+
${cost.level === 'over'
|
|
705
|
+
? 'Shipping and art generation are paused until an Archon raises your budget.'
|
|
706
|
+
: 'At 100% these actions pause until an Archon raises your budget.'}
|
|
707
|
+
</div>`
|
|
708
|
+
: '';
|
|
709
|
+
|
|
710
|
+
// task 1013 / ADR 0073: the consistent "Action needed" / "we cover it"
|
|
711
|
+
// banner. Renders the highest-priority `needs` item that isn't satisfied —
|
|
712
|
+
// amber + a Settings button when the system needs an input (e.g. the image
|
|
713
|
+
// key once free coverage ends), a calm green note while we cover it. Reads
|
|
714
|
+
// the SAME /me `needs` data the CLI session nudge does (builder-needs.js).
|
|
715
|
+
const need = (needs && Array.isArray(needs.items))
|
|
716
|
+
? needs.items.find((n) => n && n.state !== 'satisfied')
|
|
717
|
+
: null;
|
|
718
|
+
const needsBanner = need
|
|
719
|
+
? `<div class="profile__needs profile__needs--${need.state === 'action_needed' ? 'action' : 'covered'}" role="status">
|
|
720
|
+
<strong>${escapeHtml(need.title)}</strong> — ${escapeHtml(need.message)}
|
|
721
|
+
${need.action && need.action.href
|
|
722
|
+
? ` <a class="profile__needs-act" href="${escapeHtml(need.action.href)}">${escapeHtml(need.action.label || 'Set it up')} →</a>`
|
|
723
|
+
: ''}
|
|
724
|
+
</div>`
|
|
725
|
+
: '';
|
|
726
|
+
|
|
727
|
+
$('#profile-body').innerHTML = `
|
|
728
|
+
${needsBanner}
|
|
729
|
+
${budgetBanner}
|
|
730
|
+
${avatar}
|
|
731
|
+
<div>
|
|
732
|
+
<div class="profile__name-row">
|
|
733
|
+
<p class="profile__name">${escapeHtml(builder.display_name || builder.github_login || 'Builder')}</p>
|
|
734
|
+
${rankBadge}
|
|
735
|
+
</div>
|
|
736
|
+
<p class="profile__handle">@${escapeHtml(builder.github_login || 'unknown')}</p>
|
|
737
|
+
<div class="profile__stats">
|
|
738
|
+
<span>Member since ${fmtDate(builder.created_at)}</span>
|
|
739
|
+
<span title="${escapeHtml(karmaTooltip)}">${karmaTrend.replace(' in 30d', ' karma in 30d')}</span>
|
|
740
|
+
${rollPlace ? `<span title="Your place on the leaderboard (top ${rollPlace.total}), ranked by ${curLabel()}.">#${rollPlace.place} on the leaderboard</span>` : ''}
|
|
741
|
+
</div>
|
|
742
|
+
<div class="profile__disciplines">
|
|
743
|
+
<span class="profile__disc-label">Preferred work:</span>
|
|
744
|
+
<span class="profile__disc-value">${prefs.length ? escapeHtml(prefs.join(', ')) : 'none chosen yet'}</span>
|
|
745
|
+
<a class="profile__disc-edit" href="/settings">Change in Settings →</a>
|
|
746
|
+
</div>
|
|
747
|
+
</div>
|
|
748
|
+
<div class="profile__credits">
|
|
749
|
+
<span class="profile__credits-num" title="${escapeHtml(drachmaeTooltip)}">${fmtNum(builder.total_credits)}</span>
|
|
750
|
+
<span class="profile__credits-label">${(window.OTBBranding?.currencyLabel?.() ?? 'credits')}</span>
|
|
751
|
+
<span class="profile__karma" title="${escapeHtml(karmaTooltip)}">
|
|
752
|
+
<span class="profile__karma-num">${fmtNum(karmaTotal)}</span>
|
|
753
|
+
<span class="profile__karma-label">karma</span>
|
|
754
|
+
</span>
|
|
755
|
+
</div>
|
|
756
|
+
`;
|
|
757
|
+
const welcomeLine = $('#welcome-line');
|
|
758
|
+
if (welcomeLine) {
|
|
759
|
+
welcomeLine.textContent = `Welcome back, ${builder.display_name || builder.github_login || 'builder'}.`;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// task 1723: the Home dashboard mirrors — quick-stat tiles + the standing
|
|
763
|
+
// summary card — are fed from the same /me payload. All writes are guarded:
|
|
764
|
+
// the widgets are registry-owned and a module could unregister them.
|
|
765
|
+
const setText = (id, text) => {
|
|
766
|
+
const el = document.getElementById(id);
|
|
767
|
+
if (el) el.textContent = text;
|
|
768
|
+
};
|
|
769
|
+
setText('stat-credits', fmtNum(builder.total_credits));
|
|
770
|
+
setText('stat-karma', fmtNum(karmaTotal));
|
|
771
|
+
setText('stat-karma-sub', karmaTrendText);
|
|
772
|
+
const statRank = document.getElementById('stat-rank');
|
|
773
|
+
if (statRank) statRank.innerHTML = builder.rank ? renderRankBadge(normalizeRank(builder.rank), { tip: false }) : '—';
|
|
774
|
+
setText('stat-rank-sub', rollPlace ? `#${rollPlace.place} on the leaderboard` : '');
|
|
775
|
+
const statsSection = document.getElementById('home-stats');
|
|
776
|
+
if (statsSection && !newcomerGated) statsSection.hidden = false;
|
|
777
|
+
|
|
778
|
+
const homeStanding = document.getElementById('home-standing-body');
|
|
779
|
+
if (homeStanding) {
|
|
780
|
+
homeStanding.innerHTML = `
|
|
781
|
+
${needsBanner}
|
|
782
|
+
${budgetBanner}
|
|
783
|
+
<div class="profile profile--compact">
|
|
784
|
+
${avatar}
|
|
785
|
+
<div>
|
|
786
|
+
<div class="profile__name-row">
|
|
787
|
+
<p class="profile__name">${escapeHtml(builder.display_name || builder.github_login || 'Builder')}</p>
|
|
788
|
+
${rankBadge}
|
|
789
|
+
</div>
|
|
790
|
+
<p class="profile__handle">@${escapeHtml(builder.github_login || 'unknown')}</p>
|
|
791
|
+
<div class="profile__stats">
|
|
792
|
+
<span>Member since ${fmtDate(builder.created_at)}</span>
|
|
793
|
+
<span>${prefs.length ? escapeHtml(prefs.join(', ')) : 'no preferred work yet'}</span>
|
|
794
|
+
</div>
|
|
795
|
+
</div>
|
|
796
|
+
</div>
|
|
797
|
+
`;
|
|
798
|
+
}
|
|
799
|
+
const homeStandingSection = document.getElementById('home-standing');
|
|
800
|
+
if (homeStandingSection && !newcomerGated) homeStandingSection.hidden = false;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// #731: renderCliTokenReissue / issueCliToken (V3.R91 #401) and
|
|
804
|
+
// renderDiscordLink / unlinkDiscord (F7, ADR 0033) moved to
|
|
805
|
+
// public-builders/settings.js — these controls now live on /builders/settings.
|
|
806
|
+
|
|
807
|
+
async function saveDisciplines() {
|
|
808
|
+
const selected = Array.from(document.querySelectorAll('.disc-pref'))
|
|
809
|
+
.filter((el) => el.checked)
|
|
810
|
+
.map((el) => el.value);
|
|
811
|
+
const btn = $('#disc-save');
|
|
812
|
+
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
|
|
813
|
+
try {
|
|
814
|
+
const resp = await patchJSON(`${API}/me/disciplines`, { preferred_disciplines: selected });
|
|
815
|
+
renderProfile(resp.builder);
|
|
816
|
+
toast(selected.length ? `Preferred work: ${selected.join(', ')}` : 'Preference cleared.');
|
|
817
|
+
} catch (err) {
|
|
818
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
819
|
+
console.error('[builders] save disciplines failed', err);
|
|
820
|
+
toast('Could not save your preference. Try again.');
|
|
821
|
+
if (btn) { btn.disabled = false; btn.textContent = 'Save'; }
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ---- first-visit welcome panel (V3.R77 #296) ----------------------------
|
|
826
|
+
// Greets a not-yet-graduated builder with their rank, what their rank
|
|
827
|
+
// unlocks, the next checklist step, and the two doors into the hall: the
|
|
828
|
+
// orientation maps and the /builder-start command. Dismissable. Reappears
|
|
829
|
+
// if the builder's onboarding stage regresses (rare — e.g. a stage row
|
|
830
|
+
// wipe in dev or an Archon-driven reset).
|
|
831
|
+
//
|
|
832
|
+
// Persistence: localStorage key `builders:welcome-dismissed:<builder_id>`
|
|
833
|
+
// stores the stage *index* (0-6) at the moment of dismissal. On every
|
|
834
|
+
// render we compute the builder's current stage index; if it's strictly
|
|
835
|
+
// less than the dismissed-at index, the panel reappears. Per-builder key
|
|
836
|
+
// so a shared browser doesn't carry one builder's dismissal to another.
|
|
837
|
+
//
|
|
838
|
+
// The panel only renders for the LOGGED-IN viewer (uses /me). It is not
|
|
839
|
+
// shown on profile pages of other builders — that's the read-only
|
|
840
|
+
// onboarding-list panel below, which is roster-visible.
|
|
841
|
+
|
|
842
|
+
// Canonical order of the onboarding stages, mirroring
|
|
843
|
+
// src/bongos/onboarding-state.js STAGES. 'graduated' is the terminal stage;
|
|
844
|
+
// when current === 'graduated' the panel hides entirely.
|
|
845
|
+
const ONBOARDING_STAGE_ORDER = [
|
|
846
|
+
'auth_complete',
|
|
847
|
+
'pick_disciplines',
|
|
848
|
+
'first_start',
|
|
849
|
+
'first_claim',
|
|
850
|
+
'ship_three',
|
|
851
|
+
'graduated',
|
|
852
|
+
];
|
|
853
|
+
|
|
854
|
+
function stageIndex(stageId) {
|
|
855
|
+
const i = ONBOARDING_STAGE_ORDER.indexOf(stageId);
|
|
856
|
+
return i === -1 ? 0 : i;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function welcomeDismissalKey(builderId) {
|
|
860
|
+
return `builders:welcome-dismissed:${builderId}`;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function readDismissedStageIndex(builderId) {
|
|
864
|
+
try {
|
|
865
|
+
const raw = window.localStorage.getItem(welcomeDismissalKey(builderId));
|
|
866
|
+
if (raw === null) return null;
|
|
867
|
+
const n = Number(raw);
|
|
868
|
+
return Number.isInteger(n) ? n : null;
|
|
869
|
+
} catch (_) {
|
|
870
|
+
// localStorage can throw in private-browsing modes. Treat as "never
|
|
871
|
+
// dismissed" — the panel renders, which is the right fail-open
|
|
872
|
+
// behaviour for an onboarding affordance.
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// #785: remember which setup config the builder picked (Desktop app vs Claude
|
|
878
|
+
// Online) so it survives a re-render / reload / tab switch — renderWelcome used
|
|
879
|
+
// to hardcode 'desktop' every time. Device-level (not per-builder): it's "how I
|
|
880
|
+
// connect from this machine". Fail-open to 'desktop' (the recommended default).
|
|
881
|
+
const SETUP_CONFIG_KEY = 'builders:setup-config';
|
|
882
|
+
function readSetupConfig() {
|
|
883
|
+
try {
|
|
884
|
+
const v = window.localStorage.getItem(SETUP_CONFIG_KEY);
|
|
885
|
+
return v === 'online' ? 'online' : 'desktop';
|
|
886
|
+
} catch (_) { return 'desktop'; }
|
|
887
|
+
}
|
|
888
|
+
function writeSetupConfig(v) {
|
|
889
|
+
try { window.localStorage.setItem(SETUP_CONFIG_KEY, v === 'online' ? 'online' : 'desktop'); } catch (_) { /* private mode — won't persist, fine */ }
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function renderWelcome(builder, onboarding) {
|
|
893
|
+
const scroll = $('#welcome-scroll');
|
|
894
|
+
const body = $('#welcome-body');
|
|
895
|
+
if (!scroll || !body) return;
|
|
896
|
+
|
|
897
|
+
// No builder yet (unauth landing) → hide and exit.
|
|
898
|
+
if (!builder || !builder.id) { scroll.hidden = true; return; }
|
|
899
|
+
|
|
900
|
+
// Graduated → hide entirely. The checklist below will also hide itself.
|
|
901
|
+
if (onboarding && onboarding.graduated) { scroll.hidden = true; return; }
|
|
902
|
+
|
|
903
|
+
// Compute current stage index. When the /me payload didn't carry an
|
|
904
|
+
// onboarding object (legacy), default to the first stage so the panel
|
|
905
|
+
// still greets the builder rather than vanishing silently.
|
|
906
|
+
const currentStageId = (onboarding && onboarding.current_stage) || ONBOARDING_STAGE_ORDER[0];
|
|
907
|
+
const currentIdx = stageIndex(currentStageId);
|
|
908
|
+
|
|
909
|
+
// Dismissal check. Reappears on stage regression (currentIdx < dismissed).
|
|
910
|
+
const dismissedAt = readDismissedStageIndex(builder.id);
|
|
911
|
+
if (dismissedAt !== null && currentIdx >= dismissedAt) {
|
|
912
|
+
scroll.hidden = true;
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const rankBadge = renderRankBadge(normalizeRank(builder.rank), { tip: true });
|
|
917
|
+
const greeting = `Welcome, ${escapeHtml(builder.display_name || builder.github_login || 'builder')}.`;
|
|
918
|
+
|
|
919
|
+
// #777: the welcome panel now shows ONLY setup — a two-config selector that
|
|
920
|
+
// gets a zero-technical newcomer from "signed into the hall" to a working
|
|
921
|
+
// `claude` session on their dev box. Both paths reach the SAME hosted dev
|
|
922
|
+
// box (Xenos are box-only, ADR 0035): the Desktop app connects over an
|
|
923
|
+
// automated SSH link (/builder-connect — no keys to copy), and Claude Online
|
|
924
|
+
// is the in-browser ttyd terminal (ADR 0038 — no install, no SSH). Steps are
|
|
925
|
+
// short, scannable, and plain-spoken (theme stays in the headings). The
|
|
926
|
+
// "what to do next" arc lives in the Path checklist below — not here.
|
|
927
|
+
// #785: restore the builder's last-chosen config (persisted in localStorage)
|
|
928
|
+
// so a re-render / reload / tab switch doesn't snap back to Desktop.
|
|
929
|
+
const setupCfg = readSetupConfig();
|
|
930
|
+
// R60 (task 1199): the hosted dev-box onboarding below is the `dev-box`
|
|
931
|
+
// feature module's UI. A vanilla Cloud Bongos instance has dev-box OFF, so
|
|
932
|
+
// there is no box to host — show the bring-your-own-machine path instead.
|
|
933
|
+
// OTB (dev-box ON) renders the existing flow verbatim, so prod is unchanged.
|
|
934
|
+
const devBoxOn = moduleOn('dev-box');
|
|
935
|
+
// The #support pointer assumes the Discord module; drop it when discord is off.
|
|
936
|
+
const supportFoot = moduleOn('discord')
|
|
937
|
+
? `Stuck on any step? <a href="${API}/auth/discord/start">Join our Discord</a> and ask in <strong>#support</strong>.`
|
|
938
|
+
: `Stuck on any step? See the project's README and docs.`;
|
|
939
|
+
const setupPanes = devBoxOn ? `
|
|
940
|
+
<p class="setup__intro">Your work runs on a dev box we host for you — there's almost nothing to install. Choose how to connect:</p>
|
|
941
|
+
<div class="setup__toggle" role="tablist" aria-label="Choose a setup">
|
|
942
|
+
<button type="button" class="setup__tab setup__tab--desktop" data-config="desktop" role="tab" aria-selected="${setupCfg === 'desktop'}">Claude Desktop app <span class="setup__tab-tag setup__tab-tag--rec">recommended</span></button>
|
|
943
|
+
<button type="button" class="setup__tab setup__tab--online" data-config="online" role="tab" aria-selected="${setupCfg === 'online'}">Claude Online <span class="setup__tab-tag">browser only</span></button>
|
|
944
|
+
</div>
|
|
945
|
+
|
|
946
|
+
<div class="setup__pane setup__pane--desktop">
|
|
947
|
+
<ol class="setup__steps">
|
|
948
|
+
<li><strong>Download the Dev Box app:</strong>
|
|
949
|
+
<a href="/downloads/devbox/mac-arm64">Mac</a> ·
|
|
950
|
+
<a href="/downloads/devbox/mac-x64">Mac (Intel)</a> ·
|
|
951
|
+
<a href="/downloads/devbox/win-x64">Windows</a> ·
|
|
952
|
+
<a href="/downloads/devbox/win-arm64">Windows (ARM)</a>.
|
|
953
|
+
<span class="setup__hint">First open: your computer warns about an unknown developer — that's
|
|
954
|
+
expected (we haven't bought the signing certificates yet; the app is ours). Mac: System Settings
|
|
955
|
+
→ Privacy & Security → “Open Anyway”. Windows: “More info” → “Run anyway”.</span></li>
|
|
956
|
+
<li><strong>Sign in from the app.</strong> It opens this hall in your browser — click one
|
|
957
|
+
<em>Approve</em> button and you're in. No terminal, nothing to paste.</li>
|
|
958
|
+
<li><strong>Flip the switch.</strong> Your dev box builds itself in a minute or two and the app
|
|
959
|
+
chimes when it's ready. It lives in the menu bar on Mac and the system tray on Windows, and it
|
|
960
|
+
wires up Claude Desktop for you along the way.</li>
|
|
961
|
+
<li><strong><a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Download Claude</a></strong>
|
|
962
|
+
for Mac or Windows, sign in with your <a href="https://claude.com" target="_blank" rel="noopener noreferrer">Claude</a>
|
|
963
|
+
account, then open <strong>Code → Environment → “${brandWorld()} Dev Box”</strong>. You're inside —
|
|
964
|
+
build from <code>/workspace</code>.</li>
|
|
965
|
+
</ol>
|
|
966
|
+
</div>
|
|
967
|
+
|
|
968
|
+
<div class="setup__pane setup__pane--online">
|
|
969
|
+
<ol class="setup__steps">
|
|
970
|
+
<li><strong>Create your <a href="https://claude.com" target="_blank" rel="noopener noreferrer">Claude</a> account.</strong></li>
|
|
971
|
+
<li><strong>Open your online environment.</strong> This is the environment you will build in — no SSH, nothing to install.
|
|
972
|
+
<div class="setup__box" data-box-panel></div>
|
|
973
|
+
</li>
|
|
974
|
+
<li><strong>Sign in when Claude prompts you.</strong> Claude starts automatically when you open the terminal.
|
|
975
|
+
<span class="setup__hint">To paste the sign-in code: <strong>Ctrl+Shift+V</strong> or right-click — plain Ctrl+V will not paste here. Claude takes over the terminal with its own interface once you're signed in. This guide stays open in its own tab. If Claude didn't start automatically, type <code>claude</code> and press Enter.</span></li>
|
|
976
|
+
<li><strong>Then drive it however you like:</strong>
|
|
977
|
+
<span class="setup__hint"><strong>Remote control (recommended)</strong> — type <code>/remote-control</code> for a link that runs this session in Claude's own web/app interface, the same UI you'd use on a computer. <strong>Or just stay in the online terminal</strong> and build right here in the browser — no remote control needed. Either way, reopening the online terminal later resumes your session right where you left it (closing the tab keeps it running).</span></li>
|
|
978
|
+
</ol>
|
|
979
|
+
</div>
|
|
980
|
+
` : `
|
|
981
|
+
<p class="setup__intro">Build from your own machine — clone the repository, run the platform locally, and sign in from the terminal.</p>
|
|
982
|
+
<div class="setup__pane">
|
|
983
|
+
<ol class="setup__steps">
|
|
984
|
+
<li><strong>Clone the repository</strong> and install dependencies (<code>npm install</code>).</li>
|
|
985
|
+
<li><strong><a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Download Claude</a></strong> (or use the Claude Code CLI) and open the project.</li>
|
|
986
|
+
<li><strong>Run <code>/builder-setup</code></strong> to authenticate this machine, then <code>/builder-start</code> to claim your first task.</li>
|
|
987
|
+
</ol>
|
|
988
|
+
</div>
|
|
989
|
+
`;
|
|
990
|
+
// task 469: a "Set up your machine / get the code" door, available in BOTH
|
|
991
|
+
// the hosted dev-box flow and the bring-your-own-machine flow. The flows
|
|
992
|
+
// above get you a session running; this section answers the separate
|
|
993
|
+
// question a first-timer always has — "where does the code come from, and
|
|
994
|
+
// how do I get it onto my own laptop?". It's the hall mirror of the primer's
|
|
995
|
+
// "Step 0 — get the project onto your machine". Collapsed by default so it
|
|
996
|
+
// doesn't crowd the recommended path; the clone block is click-to-copy
|
|
997
|
+
// (copy-on-click.js picks up .welcome__paste). The commands mirror
|
|
998
|
+
// scripts/setup-builder.sh's own header verbatim so they can't drift.
|
|
999
|
+
// The clone commands come from the instance's own repo coords (loaded from
|
|
1000
|
+
// GET /api/bongos/instance), not a hardcoded repo, so a fresh instance's newcomer
|
|
1001
|
+
// clones ITS project (ADR 0062 §3, task 1705). Falls back to generic guidance
|
|
1002
|
+
// until the coords load (or if they're unavailable).
|
|
1003
|
+
const cloneBlock = instanceRepo
|
|
1004
|
+
? `git clone ${escapeHtml(instanceRepo.url)}.git\ncd ${escapeHtml(instanceRepo.repo)}\n./scripts/setup-builder.sh`
|
|
1005
|
+
: `# clone your instance's repository, cd into it, then run:\n./scripts/setup-builder.sh`;
|
|
1006
|
+
const getCode = `
|
|
1007
|
+
<details class="setup__getcode">
|
|
1008
|
+
<summary class="setup__getcode-summary">Set up your machine / get the code</summary>
|
|
1009
|
+
<div class="setup__getcode-body">
|
|
1010
|
+
<p class="setup__hint">First install <a href="https://git-scm.com/downloads" target="_blank" rel="noopener noreferrer">Git</a>
|
|
1011
|
+
and <a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Claude Code</a> — that's all the tooling you need up front.</p>
|
|
1012
|
+
<p class="setup__getcode-lede">Then copy the project onto your machine and run the one-shot bootstrap (it checks your setup, installs dependencies, and signs you in):</p>
|
|
1013
|
+
<code class="welcome__paste">${cloneBlock}</code>
|
|
1014
|
+
<p class="setup__hint">On Windows, run <code>node scripts/setup-builder.js</code> instead of the last line. New to the steps? The
|
|
1015
|
+
<a href="/primer">primer</a> walks the whole path in plain language.</p>
|
|
1016
|
+
</div>
|
|
1017
|
+
</details>`;
|
|
1018
|
+
body.innerHTML = `
|
|
1019
|
+
<div class="welcome__lede">${greeting} Your standing is ${rankBadge}.</div>
|
|
1020
|
+
<div class="setup" data-config="${setupCfg}">
|
|
1021
|
+
${setupPanes}
|
|
1022
|
+
${getCode}
|
|
1023
|
+
<p class="setup__foot">${supportFoot}</p>
|
|
1024
|
+
</div>
|
|
1025
|
+
`;
|
|
1026
|
+
// Remember the stage we rendered for, so the dismiss handler can record it.
|
|
1027
|
+
scroll.dataset.stageIdx = String(currentIdx);
|
|
1028
|
+
scroll.hidden = false;
|
|
1029
|
+
// #783/#807: mount the shared dev-box panel into the online pane and paint
|
|
1030
|
+
// it from live server state. Browser-only onboarding hides SSH details
|
|
1031
|
+
// (showSsh:false) — a newcomer sees only the online terminal. The panel runs
|
|
1032
|
+
// its own poll while anything is in flight (survives tab flips / re-renders /
|
|
1033
|
+
// reloads / background approvals); mount re-binds to the freshly-rendered DOM.
|
|
1034
|
+
const boxMount = body.querySelector('[data-box-panel]');
|
|
1035
|
+
if (boxMount) onboardingBox().mount(boxMount);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// #777/#783/#807: the dev-box management UI now lives in the shared OTBBoxPanel
|
|
1039
|
+
// module (public-builders/box-panel.js) so it can be reused verbatim on the
|
|
1040
|
+
// Settings page. Here it runs in browser-only mode (showSsh:false) — a newcomer
|
|
1041
|
+
// sees only the online terminal, never raw SSH details. We hold ONE instance;
|
|
1042
|
+
// it owns its own self-rescheduling poll + terminal-window handle. Lazily
|
|
1043
|
+
// created so it survives the very first renderWelcome regardless of which
|
|
1044
|
+
// <script> finished loading first; a no-op shim keeps callers safe if the
|
|
1045
|
+
// module somehow failed to load.
|
|
1046
|
+
let _onboardingBox = null;
|
|
1047
|
+
function onboardingBox() {
|
|
1048
|
+
if (!_onboardingBox && window.OTBBoxPanel) {
|
|
1049
|
+
_onboardingBox = window.OTBBoxPanel.create({ api: API, showSsh: false, toast });
|
|
1050
|
+
}
|
|
1051
|
+
return _onboardingBox || { mount() {}, refresh() {}, stop() {} };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// ---- onboarding checklist (V3.R75 #294; reshaped #777) ------------------
|
|
1055
|
+
// Renders the "Path into the City" checklist for the CURRENT logged-in
|
|
1056
|
+
// builder. Hidden once graduated (current_stage === 'graduated') so it
|
|
1057
|
+
// doesn't clutter the hall for full citizens. The displayed list (#777) is a
|
|
1058
|
+
// short, action-only orientation path: Join Discord → Choose craft → Run
|
|
1059
|
+
// /builder-setup → Ship three (→ Thetes). Every step AUTO-detects server-side
|
|
1060
|
+
// (#777, per Lars: no manual ticks). Only two carry a control:
|
|
1061
|
+
// * pick_disciplines — the craft picker (PATCH /me/disciplines).
|
|
1062
|
+
// * join_discord — a Connect-Discord button (the existing OAuth flow,
|
|
1063
|
+
// auto-joins + assigns rank role); clears when linked.
|
|
1064
|
+
// cli_setup clears once the GDS CLI is authenticated (db.hasCliSession) and
|
|
1065
|
+
// ship_three on the 3rd ship — both passive. The terminal ship_three step
|
|
1066
|
+
// shows the live "N of 3" counter (its `detail`). `discord` is the /me discord
|
|
1067
|
+
// block ({ linked, configured }) — when Discord isn't configured server-side
|
|
1068
|
+
// the Connect button is suppressed.
|
|
1069
|
+
|
|
1070
|
+
function renderOnboarding(onboarding, discord) {
|
|
1071
|
+
const scroll = $('#onboarding-scroll');
|
|
1072
|
+
const list = $('#onboarding-list');
|
|
1073
|
+
if (!scroll || !list) return;
|
|
1074
|
+
if (!onboarding) {
|
|
1075
|
+
scroll.hidden = true;
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
// Hide the panel once the builder has graduated — no point lingering.
|
|
1079
|
+
if (onboarding.graduated) {
|
|
1080
|
+
scroll.hidden = true;
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
scroll.hidden = false;
|
|
1084
|
+
|
|
1085
|
+
const discordConfigured = !!(discord && discord.configured);
|
|
1086
|
+
const stages = Array.isArray(onboarding.stages) ? onboarding.stages : [];
|
|
1087
|
+
if (stages.length === 0) {
|
|
1088
|
+
list.innerHTML = `<li class="onboarding-item onboarding-item--placeholder">No onboarding steps recorded yet.</li>`;
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// task 470: a static, INFORMATIONAL lead-in step that sits AHEAD of the
|
|
1093
|
+
// data-driven steps — the genuine first action into the city is "obtain the
|
|
1094
|
+
// repo" (clone it, or open the hosted dev box where it's already cloned).
|
|
1095
|
+
// It mirrors onboarding-state.js PRE_AUTH_STEP. It is NOT one of the tracked
|
|
1096
|
+
// checklist steps (it carries no engine stage, never gates graduation, and
|
|
1097
|
+
// isn't counted in the "N of M steps cleared" status line) — it just makes
|
|
1098
|
+
// the checklist a newcomer sees acknowledge the very first real action,
|
|
1099
|
+
// which the auto-ticking steps below silently assumed already happened.
|
|
1100
|
+
const preAuthLead = `
|
|
1101
|
+
<li class="onboarding-item onboarding-item--informational" data-stage="obtain_repo">
|
|
1102
|
+
<span class="onboarding-item__mark" aria-hidden="true">›</span>
|
|
1103
|
+
<div class="onboarding-item__body">
|
|
1104
|
+
<p class="onboarding-item__label">First — obtain the repo</p>
|
|
1105
|
+
<p class="onboarding-item__hint">Clone the project to your machine, or open your hosted dev box (where it is already cloned). The build commands live inside the repo, so this comes first. The remaining steps below are tracked automatically.</p>
|
|
1106
|
+
</div>
|
|
1107
|
+
</li>`;
|
|
1108
|
+
|
|
1109
|
+
list.innerHTML = preAuthLead + stages.map((s) => {
|
|
1110
|
+
const status = s.cleared ? 'cleared' : (s.is_current ? 'current' : 'locked');
|
|
1111
|
+
const mark = s.cleared ? '✓' : (s.is_current ? '◆' : '○');
|
|
1112
|
+
const hint = s.hint ? `<p class="onboarding-item__hint">${escapeHtml(s.hint)}</p>` : '';
|
|
1113
|
+
// #766: the terminal 'ship_three' stage carries a live "N of 3" counter
|
|
1114
|
+
// in its `detail` field — render it as a small progress line so the
|
|
1115
|
+
// builder watches themselves climb toward Thetes with each ship.
|
|
1116
|
+
const detail = s.detail
|
|
1117
|
+
? `<p class="onboarding-item__detail">${escapeHtml(s.detail)}</p>`
|
|
1118
|
+
: '';
|
|
1119
|
+
// Per-step affordance, shown ONLY while the step is the current, uncleared
|
|
1120
|
+
// one — every step now behaves uniformly (Lars, #833): locked/future steps
|
|
1121
|
+
// and cleared steps carry no control, so the path reads as one action at a
|
|
1122
|
+
// time. (The Discord step already did this; the craft picker and the
|
|
1123
|
+
// See-the-works link used to render on locked/cleared steps too.) Every
|
|
1124
|
+
// step auto-detects its own completion server-side (#777): join_discord
|
|
1125
|
+
// clears on the Discord link, cli_setup once the GDS CLI is authenticated,
|
|
1126
|
+
// ship_three at the third ship — no manual ticking.
|
|
1127
|
+
let action = '';
|
|
1128
|
+
if (s.is_current && !s.cleared) {
|
|
1129
|
+
if (s.id === 'pick_disciplines') {
|
|
1130
|
+
// V3 (2026-05-30): inline discipline picker → PATCH /me/disciplines.
|
|
1131
|
+
// Multi-select: each button is a toggle reflecting the saved prefs, so
|
|
1132
|
+
// a builder can pick engineer, artist, ideator, or any mix while the
|
|
1133
|
+
// step is current. (Settings can revise crafts later for graduated citizens.)
|
|
1134
|
+
const prefs = (lastMeState && lastMeState.builder && Array.isArray(lastMeState.builder.preferred_disciplines))
|
|
1135
|
+
? lastMeState.builder.preferred_disciplines : [];
|
|
1136
|
+
const discBtn = (value, label) => {
|
|
1137
|
+
const on = prefs.includes(value);
|
|
1138
|
+
return `<button type="button" class="btn-ghost onboarding-item__disc${on ? ' onboarding-item__disc--on' : ''}" data-disc="${value}" aria-pressed="${on}">${label}</button>`;
|
|
1139
|
+
};
|
|
1140
|
+
// R59 (task 1198): 'artist' is offered only when the art-pipeline module
|
|
1141
|
+
// is enabled — engineer + ideator are methodology core. moduleOn defaults
|
|
1142
|
+
// to true if the flag is missing, so OTB (art on) is unchanged; a vanilla
|
|
1143
|
+
// instance hides the button the server would reject anyway.
|
|
1144
|
+
action = `
|
|
1145
|
+
<div class="onboarding-item__action onboarding-item__disc-pick">
|
|
1146
|
+
${discBtn('engineer', 'Engineer')}
|
|
1147
|
+
${OTB.moduleOn('art-pipeline') ? discBtn('artist', 'Artist') : ''}
|
|
1148
|
+
${discBtn('ideator', 'Ideator')}
|
|
1149
|
+
</div>`;
|
|
1150
|
+
} else if (s.id === 'ship_three') {
|
|
1151
|
+
action = `
|
|
1152
|
+
<div class="onboarding-item__action">
|
|
1153
|
+
<a class="btn-ghost" href="/work">See the work board →</a>
|
|
1154
|
+
</div>`;
|
|
1155
|
+
} else if (s.id === 'join_discord' && discordConfigured) {
|
|
1156
|
+
// #777: the Connect button runs the existing OAuth flow (GET
|
|
1157
|
+
// /auth/discord/start → auto-join + rank role). Full-page nav so the
|
|
1158
|
+
// server can set the CSRF state cookie before redirecting to Discord.
|
|
1159
|
+
// The step then clears itself once the link lands (discord.linked) —
|
|
1160
|
+
// no manual mark. Same control as /builders/settings.
|
|
1161
|
+
action = `
|
|
1162
|
+
<div class="onboarding-item__action">
|
|
1163
|
+
<a class="btn-ghost onboarding-item__discord" href="${API}/auth/discord/start">Connect Discord & join the server</a>
|
|
1164
|
+
</div>`;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return `
|
|
1168
|
+
<li class="onboarding-item onboarding-item--${status}" data-stage="${escapeHtml(s.id)}">
|
|
1169
|
+
<span class="onboarding-item__mark" aria-hidden="true">${mark}</span>
|
|
1170
|
+
<div class="onboarding-item__body">
|
|
1171
|
+
<p class="onboarding-item__label">${escapeHtml(s.label)}</p>
|
|
1172
|
+
${hint}
|
|
1173
|
+
${detail}
|
|
1174
|
+
</div>
|
|
1175
|
+
${action}
|
|
1176
|
+
</li>
|
|
1177
|
+
`;
|
|
1178
|
+
}).join('');
|
|
1179
|
+
|
|
1180
|
+
const cleared = onboarding.progress?.cleared ?? 0;
|
|
1181
|
+
const total = onboarding.progress?.total ?? stages.length;
|
|
1182
|
+
const status = $('#onboarding-status');
|
|
1183
|
+
if (status) {
|
|
1184
|
+
status.hidden = false;
|
|
1185
|
+
status.textContent = `${cleared} of ${total} steps cleared.`;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
// V3 (2026-05-30): clear the pick_disciplines onboarding stage from the path
|
|
1191
|
+
// checklist. Saves the chosen craft via the same PATCH /me/disciplines route
|
|
1192
|
+
// the profile uses, then re-renders the checklist (and the profile picker) so
|
|
1193
|
+
// both reflect the choice. The route echoes the updated onboarding state.
|
|
1194
|
+
async function pickDiscipline(value) {
|
|
1195
|
+
// Multi-select toggle: add the craft if not yet chosen, remove it if it is,
|
|
1196
|
+
// then save the full set (any mix of engineer, artist, ideator).
|
|
1197
|
+
const current = (lastMeState && lastMeState.builder && Array.isArray(lastMeState.builder.preferred_disciplines))
|
|
1198
|
+
? lastMeState.builder.preferred_disciplines : [];
|
|
1199
|
+
const selected = current.includes(value)
|
|
1200
|
+
? current.filter((d) => d !== value)
|
|
1201
|
+
: [...current, value];
|
|
1202
|
+
const buttons = document.querySelectorAll('.onboarding-item__disc');
|
|
1203
|
+
buttons.forEach((b) => { b.disabled = true; });
|
|
1204
|
+
try {
|
|
1205
|
+
const resp = await patchJSON(`${API}/me/disciplines`, { preferred_disciplines: selected });
|
|
1206
|
+
if (resp.builder && lastMeState) {
|
|
1207
|
+
// Keep the cached /me state and the profile picker in sync.
|
|
1208
|
+
lastMeState.builder = resp.builder;
|
|
1209
|
+
lastMeState.onboarding = resp.onboarding || lastMeState.onboarding;
|
|
1210
|
+
}
|
|
1211
|
+
if (resp.onboarding) renderOnboarding(resp.onboarding, lastMeState && lastMeState.discord);
|
|
1212
|
+
if (resp.builder) renderProfile(resp.builder);
|
|
1213
|
+
toast(selected.length ? `Craft: ${selected.join(', ')}.` : 'Craft cleared.');
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
1216
|
+
console.error('[builders] pick discipline failed', err);
|
|
1217
|
+
toast('Could not save just now. Try again.');
|
|
1218
|
+
buttons.forEach((b) => { b.disabled = false; });
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// ---- achievements --------------------------------------------------------
|
|
1223
|
+
|
|
1224
|
+
function renderAchievements(all, unlocked, lockedWithProgress) {
|
|
1225
|
+
const unlockedById = new Map(unlocked.map((u) => [u.achievement_id, u]));
|
|
1226
|
+
// Map achievement_id → progress_text for locked entries. Falsy/empty
|
|
1227
|
+
// progress just shows the credit-bonus line as before.
|
|
1228
|
+
const progressById = new Map(
|
|
1229
|
+
(lockedWithProgress || []).map((l) => [l.achievement_id, l.progress_text || '']),
|
|
1230
|
+
);
|
|
1231
|
+
const grid = $('#ach-grid');
|
|
1232
|
+
if (!all.length) {
|
|
1233
|
+
grid.innerHTML = `<div class="profile__placeholder">No achievements earned yet.</div>`;
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
grid.innerHTML = all.map((a) => {
|
|
1237
|
+
const got = unlockedById.get(a.id);
|
|
1238
|
+
const cls = got ? 'ach' : 'ach ach--locked';
|
|
1239
|
+
const bonus = a.credit_bonus ? `<p class="ach__bonus">+${fmtNum(a.credit_bonus)} ${(window.OTBBranding?.currencyLabel?.() ?? 'credits')}</p>` : '';
|
|
1240
|
+
const when = got ? `<p class="ach__bonus">Won ${fmtDate(got.unlocked_at)}</p>` : '';
|
|
1241
|
+
const progress = !got && progressById.has(a.id) && progressById.get(a.id)
|
|
1242
|
+
? `<p class="ach__progress">${escapeHtml(progressById.get(a.id))}</p>`
|
|
1243
|
+
: '';
|
|
1244
|
+
return `
|
|
1245
|
+
<div class="${cls}" title="${escapeHtml(a.id)}">
|
|
1246
|
+
<div class="ach__icon" aria-hidden="true">${escapeHtml(a.icon || '✦')}</div>
|
|
1247
|
+
<div class="ach__body">
|
|
1248
|
+
<p class="ach__name">${escapeHtml(a.name)}</p>
|
|
1249
|
+
<p class="ach__desc">${escapeHtml(a.description || '')}</p>
|
|
1250
|
+
${when || bonus}
|
|
1251
|
+
${progress}
|
|
1252
|
+
</div>
|
|
1253
|
+
</div>
|
|
1254
|
+
`;
|
|
1255
|
+
}).join('');
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// ---- recent ships --------------------------------------------------------
|
|
1259
|
+
|
|
1260
|
+
// All shipped works for this builder, fetched once via ?limit=all then
|
|
1261
|
+
// filtered, sorted, and paged entirely client-side. Mirrors the status site's
|
|
1262
|
+
// Lately Inscribed pattern (modules/status-ui/public/status.js renderShippedPage), with
|
|
1263
|
+
// an additional version filter since a builder's history spans versions.
|
|
1264
|
+
const SHIPS_PAGE_SIZE = 10;
|
|
1265
|
+
const shipState = {
|
|
1266
|
+
all: [],
|
|
1267
|
+
page: 0,
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
function filterShips(ships) {
|
|
1271
|
+
const win = $('#f-ships-window')?.value || 'all';
|
|
1272
|
+
const ver = $('#f-ships-version')?.value || '';
|
|
1273
|
+
let cutoff = null;
|
|
1274
|
+
if (win !== 'all') {
|
|
1275
|
+
const days = Number(win);
|
|
1276
|
+
if (Number.isFinite(days) && days > 0) {
|
|
1277
|
+
cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
return ships.filter((s) => {
|
|
1281
|
+
if (ver && s.version_id !== ver) return false;
|
|
1282
|
+
if (cutoff !== null) {
|
|
1283
|
+
const t = s.shipped_at ? new Date(s.shipped_at).getTime() : 0;
|
|
1284
|
+
if (!t || t < cutoff) return false;
|
|
1285
|
+
}
|
|
1286
|
+
return true;
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// task 1957 — a shipped row's payout is the ACTUAL ledger figure (credits_awarded),
|
|
1291
|
+
// not the creation-time estimate. taskCreditValue picks the right one by status.
|
|
1292
|
+
function shipCredit(s) {
|
|
1293
|
+
return Number(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward) || 0;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function sortShips(ships, mode) {
|
|
1297
|
+
const copy = ships.slice();
|
|
1298
|
+
if (mode === 'oldest') {
|
|
1299
|
+
copy.sort((a, b) => new Date(a.shipped_at || 0) - new Date(b.shipped_at || 0));
|
|
1300
|
+
} else if (mode === 'drachmae-high') {
|
|
1301
|
+
copy.sort((a, b) => shipCredit(b) - shipCredit(a));
|
|
1302
|
+
} else if (mode === 'drachmae-low') {
|
|
1303
|
+
copy.sort((a, b) => shipCredit(a) - shipCredit(b));
|
|
1304
|
+
} else {
|
|
1305
|
+
// default: most recent first
|
|
1306
|
+
copy.sort((a, b) => new Date(b.shipped_at || 0) - new Date(a.shipped_at || 0));
|
|
1307
|
+
}
|
|
1308
|
+
return copy;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function renderRecentShips() {
|
|
1312
|
+
const tbody = $('#recent-ships tbody');
|
|
1313
|
+
const countEl = $('#ships-count');
|
|
1314
|
+
const pagerEl = $('#ships-pager');
|
|
1315
|
+
const rangeEl = $('#ships-range');
|
|
1316
|
+
const prevBtn = $('#ships-prev');
|
|
1317
|
+
const nextBtn = $('#ships-next');
|
|
1318
|
+
|
|
1319
|
+
const filtered = filterShips(shipState.all);
|
|
1320
|
+
const mode = $('#f-ships-sort')?.value || 'recent';
|
|
1321
|
+
const sorted = sortShips(filtered, mode);
|
|
1322
|
+
|
|
1323
|
+
// task 1723: Home mirrors — the shipped-count stat tile and the compact
|
|
1324
|
+
// 5-row recent-work table are fed from the same feed. Guarded writes.
|
|
1325
|
+
const statShipped = document.getElementById('stat-shipped');
|
|
1326
|
+
if (statShipped) statShipped.textContent = fmtNum(shipState.all.length);
|
|
1327
|
+
const homeRecent = document.querySelector('#home-recent-table tbody');
|
|
1328
|
+
if (homeRecent) {
|
|
1329
|
+
const latest = sortShips(shipState.all, 'recent').slice(0, 5);
|
|
1330
|
+
homeRecent.innerHTML = latest.length
|
|
1331
|
+
? latest.map((s) => `
|
|
1332
|
+
<tr>
|
|
1333
|
+
<td class="ledger__num">#${s.id}</td>
|
|
1334
|
+
<td>${escapeHtml(s.title)}</td>
|
|
1335
|
+
<td class="ledger__drachmae">${fmtNum(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward)}</td>
|
|
1336
|
+
<td class="ledger__date">${fmtDate(s.shipped_at)}</td>
|
|
1337
|
+
</tr>`).join('')
|
|
1338
|
+
: emptyRow(4, 'Nothing shipped yet. Claim a task on the Work board to get started.');
|
|
1339
|
+
}
|
|
1340
|
+
const homeRecentSection = document.getElementById('home-recent');
|
|
1341
|
+
if (homeRecentSection && !newcomerGated) homeRecentSection.hidden = false;
|
|
1342
|
+
|
|
1343
|
+
if (countEl) {
|
|
1344
|
+
const total = shipState.all.length;
|
|
1345
|
+
const shown = sorted.length;
|
|
1346
|
+
countEl.textContent = total
|
|
1347
|
+
? (shown === total
|
|
1348
|
+
? `${fmtNum(total)} work${total === 1 ? '' : 's'} shipped`
|
|
1349
|
+
: `${fmtNum(shown)} of ${fmtNum(total)} in view`)
|
|
1350
|
+
: '';
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
if (!sorted.length) {
|
|
1354
|
+
tbody.innerHTML = shipState.all.length
|
|
1355
|
+
? emptyRow(4, 'No work matches these filters. Widen the window or pick another version.')
|
|
1356
|
+
: emptyRow(4, 'Nothing shipped yet. Claim a task on the Work board to get started.');
|
|
1357
|
+
if (pagerEl) pagerEl.hidden = true;
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const { start } = renderPager(shipState, sorted.length, SHIPS_PAGE_SIZE, { pagerEl, rangeEl, prevBtn, nextBtn });
|
|
1362
|
+
const slice = sorted.slice(start, start + SHIPS_PAGE_SIZE);
|
|
1363
|
+
|
|
1364
|
+
tbody.innerHTML = slice.map((s) => `
|
|
1365
|
+
<tr>
|
|
1366
|
+
<td class="ledger__num">#${s.id}</td>
|
|
1367
|
+
<td>
|
|
1368
|
+
<div>${escapeHtml(s.title)}</div>
|
|
1369
|
+
${s.value_summary ? `<div style="color:var(--ink-soft);font-size:0.88rem;font-style:italic;margin-top:2px">${escapeHtml(s.value_summary)}</div>` : ''}
|
|
1370
|
+
</td>
|
|
1371
|
+
<td class="ledger__drachmae">${fmtNum(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward)}</td>
|
|
1372
|
+
<td class="ledger__date">${fmtDate(s.shipped_at)}</td>
|
|
1373
|
+
</tr>
|
|
1374
|
+
`).join('');
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Re-render when filters change. Reset to page 0 so the new filter starts at
|
|
1378
|
+
// the top — otherwise switching to a smaller window can leave you on page 9
|
|
1379
|
+
// of a 2-page result.
|
|
1380
|
+
function onShipsFilterChange() {
|
|
1381
|
+
shipState.page = 0;
|
|
1382
|
+
renderRecentShips();
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Populate the ship-version filter from the versions API response. Called
|
|
1386
|
+
// from loadAll once both versionsResp and the ships response are ready.
|
|
1387
|
+
function populateShipVersionFilter(versions) {
|
|
1388
|
+
const sel = $('#f-ships-version');
|
|
1389
|
+
if (!sel) return;
|
|
1390
|
+
// Build from the versions we've shipped to, intersected with the catalog.
|
|
1391
|
+
const seen = new Set(shipState.all.map((s) => s.version_id).filter(Boolean));
|
|
1392
|
+
for (const v of versions) {
|
|
1393
|
+
if (!seen.has(v.id)) continue;
|
|
1394
|
+
const opt = document.createElement('option');
|
|
1395
|
+
opt.value = v.id;
|
|
1396
|
+
opt.textContent = v.id;
|
|
1397
|
+
if (v.name && v.name !== v.id) opt.title = v.name;
|
|
1398
|
+
sel.appendChild(opt);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// ---- recent grades (task 197) -------------------------------------------
|
|
1403
|
+
|
|
1404
|
+
function renderRecentGrades(grades) {
|
|
1405
|
+
const tbody = $('#recent-grades tbody');
|
|
1406
|
+
if (!tbody) return;
|
|
1407
|
+
if (!grades || !grades.length) {
|
|
1408
|
+
tbody.innerHTML = emptyRow(5, 'No work reviewed yet.');
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
tbody.innerHTML = grades.map((g) => {
|
|
1412
|
+
const score = Number(g.score).toFixed(1);
|
|
1413
|
+
const verdict = g.passed
|
|
1414
|
+
? `<span style="color:var(--olive,#5b7a4d);font-weight:600">passed</span>`
|
|
1415
|
+
: `<span style="color:#a04040;font-weight:600">fell short</span>`;
|
|
1416
|
+
return `
|
|
1417
|
+
<tr>
|
|
1418
|
+
<td class="ledger__num">#${g.task_id}</td>
|
|
1419
|
+
<td>${escapeHtml(g.title || '')}</td>
|
|
1420
|
+
<td class="ledger__drachmae">${score} / 10</td>
|
|
1421
|
+
<td>${verdict}</td>
|
|
1422
|
+
<td class="ledger__date">${fmtDate(g.graded_at)}</td>
|
|
1423
|
+
</tr>
|
|
1424
|
+
`;
|
|
1425
|
+
}).join('');
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function renderRollingNote(rolling) {
|
|
1429
|
+
const note = $('#grades-rolling-note');
|
|
1430
|
+
if (!note) return;
|
|
1431
|
+
if (!rolling || !rolling.n || Number(rolling.n) === 0) {
|
|
1432
|
+
note.hidden = true;
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const avg = Number(rolling.avg_score).toFixed(1);
|
|
1436
|
+
const passed = Number(rolling.n_passed || 0);
|
|
1437
|
+
const n = Number(rolling.n);
|
|
1438
|
+
note.textContent = `Last 14 days: ${avg} / 10 average across ${n} graded work${n === 1 ? '' : 's'} (${passed} passed).`;
|
|
1439
|
+
note.hidden = false;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// ---- the dev curve (V3.R72 #291, C5) ------------------------------------
|
|
1443
|
+
//
|
|
1444
|
+
// Per-builder historical analytics: ship rate, cost-per-task, duration, and
|
|
1445
|
+
// grade pass-rate over time, plus a top-kinds fingerprint. Charts are inline
|
|
1446
|
+
// SVG sparklines — no chart library (the task spec's explicit constraint).
|
|
1447
|
+
// An Archon additionally gets a "You / All builders" scope toggle backed by
|
|
1448
|
+
// the ecosystem aggregate (individuals stripped), satisfying the
|
|
1449
|
+
// cross-builder view requirement without a per-builder roster on this page.
|
|
1450
|
+
|
|
1451
|
+
const analyticsState = { builderId: null, isArchon: false, scope: 'self', self: null, eco: null };
|
|
1452
|
+
|
|
1453
|
+
// Build a tiny SVG line sparkline from an array of numeric y-values. Returns
|
|
1454
|
+
// a self-contained <svg> string. A flat/short series degrades to a baseline.
|
|
1455
|
+
function sparkline(values, opts = {}) {
|
|
1456
|
+
const W = opts.width || 260;
|
|
1457
|
+
const H = opts.height || 46;
|
|
1458
|
+
const pad = 3;
|
|
1459
|
+
const color = opts.color || 'var(--accent-deep)';
|
|
1460
|
+
const nums = (values || []).map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0));
|
|
1461
|
+
if (nums.length === 0) {
|
|
1462
|
+
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="no data"></svg>`;
|
|
1463
|
+
}
|
|
1464
|
+
const min = Math.min(...nums);
|
|
1465
|
+
const max = Math.max(...nums);
|
|
1466
|
+
const span = max - min || 1;
|
|
1467
|
+
const n = nums.length;
|
|
1468
|
+
const x = (i) => (n === 1 ? W / 2 : pad + (i * (W - 2 * pad)) / (n - 1));
|
|
1469
|
+
const y = (v) => H - pad - ((v - min) / span) * (H - 2 * pad);
|
|
1470
|
+
const pts = nums.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`);
|
|
1471
|
+
const line = pts.join(' ');
|
|
1472
|
+
const area = `${pad},${H - pad} ${line} ${(W - pad)},${H - pad}`;
|
|
1473
|
+
const last = nums[n - 1];
|
|
1474
|
+
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="trend, latest ${last}">
|
|
1475
|
+
<polyline class="spark__area" points="${area}" fill="${color}" />
|
|
1476
|
+
<polyline class="spark__line" points="${line}" fill="none" stroke="${color}" />
|
|
1477
|
+
${n === 1 ? `<circle cx="${x(0).toFixed(1)}" cy="${y(last).toFixed(1)}" r="2.4" fill="${color}" />` : ''}
|
|
1478
|
+
</svg>`;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function statCard(label, value, sub) {
|
|
1482
|
+
return `<div class="curve__stat">
|
|
1483
|
+
<div class="curve__stat-value">${escapeHtml(String(value))}</div>
|
|
1484
|
+
<div class="curve__stat-label">${escapeHtml(label)}</div>
|
|
1485
|
+
${sub ? `<div class="curve__stat-sub">${escapeHtml(sub)}</div>` : ''}
|
|
1486
|
+
</div>`;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function chartCard(title, values, color, footer) {
|
|
1490
|
+
const has = Array.isArray(values) && values.length > 0;
|
|
1491
|
+
return `<div class="curve__chart">
|
|
1492
|
+
<div class="curve__chart-title">${escapeHtml(title)}</div>
|
|
1493
|
+
${has ? sparkline(values, { color }) : `<div class="curve__chart-empty">no data in window</div>`}
|
|
1494
|
+
${footer ? `<div class="curve__chart-foot">${escapeHtml(footer)}</div>` : ''}
|
|
1495
|
+
</div>`;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const pct = (v) => (v == null ? '—' : `${Math.round(Number(v) * 100)}%`);
|
|
1499
|
+
const usd = (v) => `$${(Number(v) || 0).toFixed(2)}`;
|
|
1500
|
+
const mins = (v) => (v == null ? '—' : `${Math.round(Number(v))}m`);
|
|
1501
|
+
|
|
1502
|
+
// Render the curve for whichever scope is active. `data` is either the self
|
|
1503
|
+
// payload (/analytics/builder/:id) or the ecosystem payload, normalized below.
|
|
1504
|
+
function renderCurveBody() {
|
|
1505
|
+
const headlineEl = $('#curve-headline');
|
|
1506
|
+
const gridEl = $('#curve-grid');
|
|
1507
|
+
const kindsEl = $('#curve-kinds');
|
|
1508
|
+
const emptyEl = $('#curve-empty');
|
|
1509
|
+
const noteEl = $('#curve-scope-note');
|
|
1510
|
+
if (!headlineEl || !gridEl || !kindsEl) return;
|
|
1511
|
+
|
|
1512
|
+
const isEco = analyticsState.scope === 'eco';
|
|
1513
|
+
const data = isEco ? analyticsState.eco : analyticsState.self;
|
|
1514
|
+
|
|
1515
|
+
// Scope toggle (Archon only).
|
|
1516
|
+
if (analyticsState.isArchon && noteEl) {
|
|
1517
|
+
noteEl.innerHTML =
|
|
1518
|
+
`<span class="curve__scope" role="group" aria-label="Analytics scope">` +
|
|
1519
|
+
`<button type="button" class="curve__scope-btn${!isEco ? ' is-active' : ''}" data-scope="self">You</button>` +
|
|
1520
|
+
`<button type="button" class="curve__scope-btn${isEco ? ' is-active' : ''}" data-scope="eco">All builders</button>` +
|
|
1521
|
+
`</span>`;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
if (!data) { gridEl.innerHTML = ''; headlineEl.innerHTML = ''; kindsEl.innerHTML = ''; return; }
|
|
1525
|
+
|
|
1526
|
+
const totalShipped = data.headline?.total_shipped || 0;
|
|
1527
|
+
if (totalShipped === 0) {
|
|
1528
|
+
if (emptyEl) emptyEl.style.display = '';
|
|
1529
|
+
headlineEl.innerHTML = ''; gridEl.innerHTML = ''; kindsEl.innerHTML = '';
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
if (emptyEl) emptyEl.style.display = 'none';
|
|
1533
|
+
|
|
1534
|
+
// Headline stat cards.
|
|
1535
|
+
const h = data.headline;
|
|
1536
|
+
const cards = [];
|
|
1537
|
+
cards.push(statCard(isEco ? 'Works shipped' : 'Works shipped', totalShipped, `last ${data.window_days}d`));
|
|
1538
|
+
if (isEco) {
|
|
1539
|
+
cards.push(statCard('Active builders', h.active_builders ?? 0));
|
|
1540
|
+
cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
|
|
1541
|
+
} else {
|
|
1542
|
+
cards.push(statCard('Cost / work', usd(h.cost_per_task)));
|
|
1543
|
+
cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
|
|
1544
|
+
cards.push(statCard('Confirmation rate', pct(h.confirmation_rate), `${h.resolved_claims || 0} claims`));
|
|
1545
|
+
cards.push(statCard('Grade pass rate', pct(h.grade_pass_rate), `${h.graded_n || 0} graded`));
|
|
1546
|
+
}
|
|
1547
|
+
headlineEl.innerHTML = cards.join('');
|
|
1548
|
+
|
|
1549
|
+
// Sparkline charts.
|
|
1550
|
+
const charts = [];
|
|
1551
|
+
charts.push(chartCard('Works shipped / day', (data.ship_rate || []).map((r) => r.n), 'var(--accent-deep)',
|
|
1552
|
+
`${(data.ship_rate || []).reduce((s, r) => s + Number(r.n), 0)} over ${data.window_days}d`));
|
|
1553
|
+
charts.push(chartCard('Time / work (min)', (data.duration_trend || []).map((r) => r.avg_actual), 'var(--sea)',
|
|
1554
|
+
'actual minutes per shipped work'));
|
|
1555
|
+
if (!isEco) {
|
|
1556
|
+
charts.push(chartCard('Cost / work ($)', (data.cost_trend || []).map((r) => r.per_work), 'var(--terracotta)',
|
|
1557
|
+
`total ${usd(h.total_cost_usd)}`));
|
|
1558
|
+
charts.push(chartCard('Grade pass rate', (data.grade_trend || []).map((r) => (r.n ? r.n_passed / r.n : 0)), 'var(--accent)',
|
|
1559
|
+
'share of work passing review'));
|
|
1560
|
+
}
|
|
1561
|
+
gridEl.innerHTML = charts.join('');
|
|
1562
|
+
|
|
1563
|
+
// Top kinds — horizontal bars.
|
|
1564
|
+
const kinds = (data.top_kinds || []).slice(0, 8);
|
|
1565
|
+
if (kinds.length) {
|
|
1566
|
+
const maxN = Math.max(...kinds.map((k) => Number(k.n)));
|
|
1567
|
+
kindsEl.innerHTML =
|
|
1568
|
+
`<div class="curve__chart-title">Kinds of work shipped</div>` +
|
|
1569
|
+
kinds.map((k) => {
|
|
1570
|
+
const w = maxN ? Math.round((Number(k.n) / maxN) * 100) : 0;
|
|
1571
|
+
return `<div class="curve__kind">
|
|
1572
|
+
<span class="curve__kind-label">${escapeHtml(k.kind)}</span>
|
|
1573
|
+
<span class="curve__kind-bar"><span class="curve__kind-fill" style="width:${w}%"></span></span>
|
|
1574
|
+
<span class="curve__kind-n">${k.n}</span>
|
|
1575
|
+
</div>`;
|
|
1576
|
+
}).join('');
|
|
1577
|
+
} else {
|
|
1578
|
+
kindsEl.innerHTML = '';
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// Fire-and-forget loader: fetch the builder's own curve (and, for an Archon,
|
|
1583
|
+
// the ecosystem aggregate) and render. A failed/empty feed simply leaves the
|
|
1584
|
+
// section hidden rather than holding up the rest of the hall.
|
|
1585
|
+
async function loadAnalytics(builder) {
|
|
1586
|
+
const scroll = $('#analytics-scroll');
|
|
1587
|
+
if (!scroll || !builder?.id) return;
|
|
1588
|
+
analyticsState.builderId = builder.id;
|
|
1589
|
+
analyticsState.isArchon = (builder.rank || '').toLowerCase() === 'archon';
|
|
1590
|
+
analyticsState.scope = 'self';
|
|
1591
|
+
try {
|
|
1592
|
+
const self = await getJSON(`${API}/analytics/builder/${builder.id}?days=90`);
|
|
1593
|
+
analyticsState.self = self;
|
|
1594
|
+
// A gated newcomer with nothing shipped: keep the section hidden.
|
|
1595
|
+
if (!self || (self.headline?.total_shipped || 0) === 0) { scroll.hidden = true; return; }
|
|
1596
|
+
scroll.hidden = false;
|
|
1597
|
+
renderCurveBody();
|
|
1598
|
+
renderNav(builder, lastMeState?.onboarding || null); // re-show the Curve chip now that the section is visible
|
|
1599
|
+
if (analyticsState.isArchon) {
|
|
1600
|
+
getJSON(`${API}/public/analytics/ecosystem?days=90`)
|
|
1601
|
+
.then((eco) => { analyticsState.eco = eco; if (analyticsState.scope === 'eco') renderCurveBody(); })
|
|
1602
|
+
.catch(() => { /* ecosystem optional */ });
|
|
1603
|
+
}
|
|
1604
|
+
} catch (err) {
|
|
1605
|
+
console.error('[builders] analytics load failed', err);
|
|
1606
|
+
scroll.hidden = true;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// ---- karma feed (V3.R26 #246) -------------------------------------------
|
|
1611
|
+
//
|
|
1612
|
+
// The recent karma_log entries for the current builder. The /me payload
|
|
1613
|
+
// already includes them under `karma.recent`. The scroll stays hidden when
|
|
1614
|
+
// the builder has no history yet (zero karma is a common starting state,
|
|
1615
|
+
// so we don't want to push an empty table at every new citizen).
|
|
1616
|
+
|
|
1617
|
+
function renderKarmaFeed(entries) {
|
|
1618
|
+
const scroll = $('#karma-scroll');
|
|
1619
|
+
const tbody = $('#karma-feed tbody');
|
|
1620
|
+
if (!scroll || !tbody) return;
|
|
1621
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
1622
|
+
// Hide the scroll entirely if the builder has no karma history.
|
|
1623
|
+
// Keeps the page tidy for fresh citizens; reappears the moment a
|
|
1624
|
+
// karma_log row lands.
|
|
1625
|
+
scroll.hidden = true;
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
// #748: a gated newcomer never shows the karma feed, even if karma exists.
|
|
1629
|
+
scroll.hidden = newcomerGated;
|
|
1630
|
+
tbody.innerHTML = entries.map((k) => {
|
|
1631
|
+
const delta = Number(k.delta) || 0;
|
|
1632
|
+
const deltaCls = signClasses(delta, 'karma-feed__delta');
|
|
1633
|
+
const deltaText = delta > 0 ? `+${fmtNum(delta)}` : fmtNum(delta);
|
|
1634
|
+
const granted = k.granted_by
|
|
1635
|
+
? `@${escapeHtml(k.granted_by_login || k.granted_by_display_name || `builder #${k.granted_by}`)}`
|
|
1636
|
+
: '<span style="opacity:0.6;">— all builders —</span>';
|
|
1637
|
+
return `
|
|
1638
|
+
<tr>
|
|
1639
|
+
<td class="ledger__date">${escapeHtml(fmtDate(k.recorded_at))}</td>
|
|
1640
|
+
<td class="karma-feed__delta ${deltaCls}">${deltaText}</td>
|
|
1641
|
+
<td>${escapeHtml(k.reason || '')}</td>
|
|
1642
|
+
<td>${granted}</td>
|
|
1643
|
+
</tr>
|
|
1644
|
+
`;
|
|
1645
|
+
}).join('');
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// ---- peer acclaim feed (V3.R25 #245) ------------------------------------
|
|
1649
|
+
//
|
|
1650
|
+
// Other builders' recently confirmed/shipped work, each with a +/- vote
|
|
1651
|
+
// widget. Votes fold into the shipping builder's karma on a weekly tally,
|
|
1652
|
+
// lagged 7 days (server-enforced). Metic+ may vote; the endpoint returns
|
|
1653
|
+
// can_vote so Xenos see the tallies read-only. The endpoint already excludes
|
|
1654
|
+
// the caller's own ships (anti-self-vote), so there's nothing to vote on that
|
|
1655
|
+
// isn't a peer's work.
|
|
1656
|
+
|
|
1657
|
+
let acclaimCanVote = false;
|
|
1658
|
+
|
|
1659
|
+
function renderAcclaimFeed(data) {
|
|
1660
|
+
const scroll = $('#acclaim-scroll');
|
|
1661
|
+
const tbody = $('#acclaim-feed tbody');
|
|
1662
|
+
if (!scroll || !tbody) return;
|
|
1663
|
+
const tasks = (data && Array.isArray(data.tasks)) ? data.tasks : [];
|
|
1664
|
+
acclaimCanVote = !!(data && data.can_vote);
|
|
1665
|
+
if (tasks.length === 0) {
|
|
1666
|
+
// Nothing to acclaim yet (e.g. the only confirmed work so far is your
|
|
1667
|
+
// own). Hide rather than show an empty ledger.
|
|
1668
|
+
scroll.hidden = true;
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
// #748: a gated newcomer never shows the acclaim feed.
|
|
1672
|
+
scroll.hidden = newcomerGated;
|
|
1673
|
+
tbody.innerHTML = tasks.map((t) => {
|
|
1674
|
+
const by = escapeHtml(t.display_name || (t.github_login ? `@${t.github_login}` : `builder #${t.shipped_by}`));
|
|
1675
|
+
const net = Number(t.net_votes) || 0;
|
|
1676
|
+
const netCls = signClasses(net, 'acclaim__net');
|
|
1677
|
+
const netText = net > 0 ? `+${net}` : `${net}`;
|
|
1678
|
+
const mine = t.my_vote == null ? 0 : Number(t.my_vote);
|
|
1679
|
+
// t.id is a bigserial integer, but coerce + escape it like every other
|
|
1680
|
+
// server-sourced value rather than trusting the type — it flows into both
|
|
1681
|
+
// innerHTML and querySelector attribute selectors below.
|
|
1682
|
+
const tid = escapeHtml(String(t.id));
|
|
1683
|
+
const controls = acclaimCanVote
|
|
1684
|
+
? `<span class="acclaim__vote" role="group" aria-label="Vote on this work">
|
|
1685
|
+
<button type="button" class="acclaim__btn acclaim__btn--up${mine === 1 ? ' is-active' : ''}" data-vote-task="${tid}" data-vote="1" aria-pressed="${mine === 1}" title="Upvote (+1)">▲</button>
|
|
1686
|
+
<button type="button" class="acclaim__btn acclaim__btn--down${mine === -1 ? ' is-active' : ''}" data-vote-task="${tid}" data-vote="-1" aria-pressed="${mine === -1}" title="Downvote (−1)">▼</button>
|
|
1687
|
+
</span>`
|
|
1688
|
+
: '<span class="acclaim__readonly" title="Metic rank and above may vote">—</span>';
|
|
1689
|
+
const title = escapeHtml(t.value_summary || t.title || `task #${t.id}`);
|
|
1690
|
+
return `
|
|
1691
|
+
<tr data-acclaim-row="${tid}">
|
|
1692
|
+
<td>${title}</td>
|
|
1693
|
+
<td>${by}</td>
|
|
1694
|
+
<td class="acclaim__net ${netCls}" data-acclaim-net="${tid}">${netText}</td>
|
|
1695
|
+
<td>${controls}</td>
|
|
1696
|
+
</tr>
|
|
1697
|
+
`;
|
|
1698
|
+
}).join('');
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
async function loadAcclaimFeed() {
|
|
1702
|
+
try {
|
|
1703
|
+
const data = await getJSON(`${API}/tasks/peer-votes/feed?limit=25`);
|
|
1704
|
+
renderAcclaimFeed(data);
|
|
1705
|
+
} catch (err) {
|
|
1706
|
+
// Non-fatal: a feed failure shouldn't take down the hall.
|
|
1707
|
+
console.warn('[hall] acclaim feed failed:', err.message);
|
|
1708
|
+
const scroll = $('#acclaim-scroll');
|
|
1709
|
+
if (scroll) scroll.hidden = true;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// Click delegation for the vote buttons. Toggling an already-active vote
|
|
1714
|
+
// clears it (sends 0). Re-fetches the row's net from the server response so
|
|
1715
|
+
// the displayed tally is always authoritative.
|
|
1716
|
+
async function onAcclaimClick(ev) {
|
|
1717
|
+
const btn = ev.target.closest('[data-vote-task]');
|
|
1718
|
+
if (!btn || !acclaimCanVote) return;
|
|
1719
|
+
// Coerce the id to an integer before it goes into a CSS attribute selector
|
|
1720
|
+
// or the URL — a non-numeric value would throw a querySelector SyntaxError.
|
|
1721
|
+
const taskIdNum = Number(btn.getAttribute('data-vote-task'));
|
|
1722
|
+
if (!Number.isInteger(taskIdNum)) return;
|
|
1723
|
+
const taskId = String(taskIdNum);
|
|
1724
|
+
const clicked = Number(btn.getAttribute('data-vote'));
|
|
1725
|
+
const wasActive = btn.classList.contains('is-active');
|
|
1726
|
+
const vote = wasActive ? 0 : clicked; // clicking your current vote clears it
|
|
1727
|
+
const row = $(`[data-acclaim-row="${taskId}"]`);
|
|
1728
|
+
const buttons = row ? row.querySelectorAll('.acclaim__btn') : [];
|
|
1729
|
+
buttons.forEach((b) => { b.disabled = true; });
|
|
1730
|
+
try {
|
|
1731
|
+
const res = await postJSON(`${API}/tasks/${encodeURIComponent(taskId)}/vote`, { vote });
|
|
1732
|
+
// Reflect the authoritative net + which button is active.
|
|
1733
|
+
const netEl = $(`[data-acclaim-net="${taskId}"]`);
|
|
1734
|
+
if (netEl) {
|
|
1735
|
+
const net = Number(res.net_votes) || 0;
|
|
1736
|
+
netEl.textContent = net > 0 ? `+${net}` : `${net}`;
|
|
1737
|
+
netEl.classList.remove('acclaim__net--up', 'acclaim__net--down', 'acclaim__net--flat');
|
|
1738
|
+
netEl.classList.add(signClasses(net, 'acclaim__net'));
|
|
1739
|
+
}
|
|
1740
|
+
buttons.forEach((b) => {
|
|
1741
|
+
const v = Number(b.getAttribute('data-vote'));
|
|
1742
|
+
const active = res.my_vote != null && Number(res.my_vote) === v;
|
|
1743
|
+
b.classList.toggle('is-active', active);
|
|
1744
|
+
b.setAttribute('aria-pressed', String(active));
|
|
1745
|
+
});
|
|
1746
|
+
} catch (err) {
|
|
1747
|
+
toast(err.status === 403 ? 'You can\'t vote on your own work.' : `Vote failed: ${err.message}`);
|
|
1748
|
+
} finally {
|
|
1749
|
+
buttons.forEach((b) => { b.disabled = false; });
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// ---- task browser (removed #750) -----------------------------------------
|
|
1754
|
+
// The claimable-works list now lives solely on the /work Work Board (#748);
|
|
1755
|
+
// the hall's old Claim section + its renderTasks/copy-prompt machinery were
|
|
1756
|
+
// removed. work.js carries the equivalent rendering for that page.
|
|
1757
|
+
|
|
1758
|
+
// ---- red-team rewards (bounty table, V3.R65 #284) ------------------------
|
|
1759
|
+
|
|
1760
|
+
// Severity tiers ship low → critical; the server already returns them in that
|
|
1761
|
+
// order (src/bongos/security.js SEVERITIES), but we cap the row classes here so
|
|
1762
|
+
// the colouring is deterministic regardless of payload ordering.
|
|
1763
|
+
function renderBountyTable(rows) {
|
|
1764
|
+
const tbody = $('#bounty-table tbody');
|
|
1765
|
+
if (!tbody) return;
|
|
1766
|
+
if (!Array.isArray(rows) || !rows.length) {
|
|
1767
|
+
tbody.innerHTML = emptyRow(3, 'The reward schedule could not be loaded.');
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
tbody.innerHTML = rows.map((r) => {
|
|
1771
|
+
const sev = escapeHtml(r.severity || '');
|
|
1772
|
+
const overridden = r.overridden
|
|
1773
|
+
? ` <span style="color:var(--ink-faint);font-size:0.82rem">(tuned)</span>`
|
|
1774
|
+
: '';
|
|
1775
|
+
return `
|
|
1776
|
+
<tr>
|
|
1777
|
+
<td style="text-transform:capitalize;font-weight:600">${sev}</td>
|
|
1778
|
+
<td class="ledger__drachmae">${fmtNum(r.drachmae)}${overridden}</td>
|
|
1779
|
+
<td style="color:var(--ink-soft)">${escapeHtml(r.blurb || '')}</td>
|
|
1780
|
+
</tr>
|
|
1781
|
+
`;
|
|
1782
|
+
}).join('');
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// ---- leaderboard ---------------------------------------------------------
|
|
1786
|
+
|
|
1787
|
+
// Locked at 8 for V2 (per limitations/pms-v2.md). Hardcoding here avoids an
|
|
1788
|
+
// extra API round-trip per page load — the catalog won't shift in V2.
|
|
1789
|
+
const ACH_EMOJI = {
|
|
1790
|
+
'first-ship': '🚢',
|
|
1791
|
+
'first-claim': '🪙',
|
|
1792
|
+
'first-parallel-claim': '⚓',
|
|
1793
|
+
'streak-3': '🔥',
|
|
1794
|
+
'streak-7': '🌋',
|
|
1795
|
+
'art-pipeline-shipper': '🎨',
|
|
1796
|
+
'gds-shipper': '⚙️',
|
|
1797
|
+
'breaking-the-100c-bar': '💯',
|
|
1798
|
+
};
|
|
1799
|
+
|
|
1800
|
+
function renderAchievementRow(ids) {
|
|
1801
|
+
if (!Array.isArray(ids) || ids.length === 0) return '';
|
|
1802
|
+
const inner = ids
|
|
1803
|
+
.map((id) => {
|
|
1804
|
+
const emoji = ACH_EMOJI[id];
|
|
1805
|
+
if (!emoji) return '';
|
|
1806
|
+
// Title attribute is the achievement id (kebab) — terse enough that hover
|
|
1807
|
+
// discloses the laurel without overstating the prose.
|
|
1808
|
+
return `<span class="lb-ach" title="${escapeHtml(id)}" aria-label="${escapeHtml(id)}">${emoji}</span>`;
|
|
1809
|
+
})
|
|
1810
|
+
.join('');
|
|
1811
|
+
return inner ? `<span class="lb-ach-row" aria-label="achievements">${inner}</span>` : '';
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
const lbState = {
|
|
1815
|
+
rows: [],
|
|
1816
|
+
page: 0,
|
|
1817
|
+
pageSize: 25, // task 1750: top-100 Roll — 25/page keeps it to a few pages
|
|
1818
|
+
|
|
1819
|
+
sortBy: 'drachmae', // 'rank' | 'drachmae' | 'karma'
|
|
1820
|
+
sortDir: 'desc', // 'desc' | 'asc'
|
|
1821
|
+
};
|
|
1822
|
+
|
|
1823
|
+
// Stable sort. Server returns rows in drachmae-DESC order, which is also the
|
|
1824
|
+
// default view; for other modes (rank, karma, drachmae-ASC), re-sort here.
|
|
1825
|
+
// The original server order is preserved as a tie-breaker so equal-value rows
|
|
1826
|
+
// don't jitter between renders. Drachmae is the secondary tie-breaker when
|
|
1827
|
+
// sorting by karma (V3.R26 #246) so the karma toggle still reads sensibly
|
|
1828
|
+
// when many builders share the same karma value (e.g. zero).
|
|
1829
|
+
function sortedLeaderboardRows() {
|
|
1830
|
+
const indexed = lbState.rows.map((row, idx) => ({ row, idx }));
|
|
1831
|
+
const dir = lbState.sortDir === 'asc' ? 1 : -1;
|
|
1832
|
+
if (lbState.sortBy === 'rank') {
|
|
1833
|
+
return indexed
|
|
1834
|
+
.sort((a, b) => {
|
|
1835
|
+
const diff = (rankWeight(a.row.rank) - rankWeight(b.row.rank)) * dir;
|
|
1836
|
+
return diff !== 0 ? diff : a.idx - b.idx;
|
|
1837
|
+
})
|
|
1838
|
+
.map(({ row }) => row);
|
|
1839
|
+
}
|
|
1840
|
+
if (lbState.sortBy === 'karma') {
|
|
1841
|
+
return indexed
|
|
1842
|
+
.sort((a, b) => {
|
|
1843
|
+
const ak = Number(a.row.karma) || 0;
|
|
1844
|
+
const bk = Number(b.row.karma) || 0;
|
|
1845
|
+
const diff = (ak - bk) * dir;
|
|
1846
|
+
if (diff !== 0) return diff;
|
|
1847
|
+
// Tie-break on drachmae DESC, then stable on server order.
|
|
1848
|
+
const ac = Number(a.row.total_credits) || 0;
|
|
1849
|
+
const bc = Number(b.row.total_credits) || 0;
|
|
1850
|
+
if (ac !== bc) return bc - ac;
|
|
1851
|
+
return a.idx - b.idx;
|
|
1852
|
+
})
|
|
1853
|
+
.map(({ row }) => row);
|
|
1854
|
+
}
|
|
1855
|
+
if (lbState.sortBy === 'drachmae') {
|
|
1856
|
+
// Server already DESCs by drachmae, so the default mode is a no-op
|
|
1857
|
+
// copy. The ASC branch flips it.
|
|
1858
|
+
if (lbState.sortDir === 'desc') return lbState.rows.slice();
|
|
1859
|
+
return indexed
|
|
1860
|
+
.sort((a, b) => {
|
|
1861
|
+
const ac = Number(a.row.total_credits) || 0;
|
|
1862
|
+
const bc = Number(b.row.total_credits) || 0;
|
|
1863
|
+
const diff = (ac - bc) * dir;
|
|
1864
|
+
return diff !== 0 ? diff : a.idx - b.idx;
|
|
1865
|
+
})
|
|
1866
|
+
.map(({ row }) => row);
|
|
1867
|
+
}
|
|
1868
|
+
return lbState.rows.slice();
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// Map a sortable column id → the lbState.sortBy key. Adding a new sortable
|
|
1872
|
+
// column means adding an entry here + a header in index.html with id
|
|
1873
|
+
// `lb-sort-<key>`. The handler walks aria-sort across all three headers so
|
|
1874
|
+
// only one column shows its arrow at a time.
|
|
1875
|
+
const LB_SORT_HEADERS = {
|
|
1876
|
+
rank: 'lb-sort-rank',
|
|
1877
|
+
drachmae: 'lb-sort-drachmae',
|
|
1878
|
+
karma: 'lb-sort-karma',
|
|
1879
|
+
};
|
|
1880
|
+
|
|
1881
|
+
// #757: this builder's place on the Roll, ranked by drachmae (total_credits)
|
|
1882
|
+
// descending — the canonical leaderboard order, independent of whatever column
|
|
1883
|
+
// the Roll table is currently sorted by. Computed client-side from the rows
|
|
1884
|
+
// already fetched for the Roll, so the Standing card shows it with no extra
|
|
1885
|
+
// round-trip. Returns { place, total } or null (rows not loaded yet, or this
|
|
1886
|
+
// builder isn't on the roll). Matched by github_login (rows carry no id).
|
|
1887
|
+
function leaderboardPlace(login) {
|
|
1888
|
+
const rows = Array.isArray(lbState.rows) ? lbState.rows : [];
|
|
1889
|
+
if (!rows.length || !login) return null;
|
|
1890
|
+
const ranked = [...rows].sort(
|
|
1891
|
+
(a, b) => (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0),
|
|
1892
|
+
);
|
|
1893
|
+
const idx = ranked.findIndex((r) => r.github_login === login);
|
|
1894
|
+
return idx === -1 ? null : { place: idx + 1, total: ranked.length };
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
function renderLeaderboard() {
|
|
1898
|
+
const rows = sortedLeaderboardRows();
|
|
1899
|
+
const start = lbState.page * lbState.pageSize;
|
|
1900
|
+
const end = start + lbState.pageSize;
|
|
1901
|
+
const slice = rows.slice(start, end);
|
|
1902
|
+
const tbody = $('#leaderboard tbody');
|
|
1903
|
+
|
|
1904
|
+
if (!slice.length) {
|
|
1905
|
+
tbody.innerHTML = emptyRow(5, 'No more builders to show.');
|
|
1906
|
+
} else {
|
|
1907
|
+
tbody.innerHTML = slice.map((b, i) => {
|
|
1908
|
+
const place = start + i + 1;
|
|
1909
|
+
const name = b.display_name || b.github_login || 'unknown builder';
|
|
1910
|
+
const badges = renderAchievementRow(b.achievements);
|
|
1911
|
+
const rank = renderRankBadge(normalizeRank(b.rank));
|
|
1912
|
+
// task 1467: link the name to the builder's public profile page. Guarded
|
|
1913
|
+
// on b.id (the /leaderboard row carries it) so a row without an id degrades
|
|
1914
|
+
// to plain text rather than a broken /profile?id=undefined link.
|
|
1915
|
+
const nameCell = b.id != null
|
|
1916
|
+
? `<a class="lb-name" href="/profile?id=${encodeURIComponent(b.id)}">${escapeHtml(name)}</a>`
|
|
1917
|
+
: escapeHtml(name);
|
|
1918
|
+
return `
|
|
1919
|
+
<tr>
|
|
1920
|
+
<td class="ledger__num">${place}</td>
|
|
1921
|
+
<td>${nameCell} <span style="color:var(--ink-faint);font-size:0.86rem">@${escapeHtml(b.github_login || '')}</span>${badges}</td>
|
|
1922
|
+
<td class="ledger__rank">${rank}</td>
|
|
1923
|
+
<td class="ledger__drachmae">${fmtNum(b.total_credits)}</td>
|
|
1924
|
+
<td class="ledger__drachmae">${fmtNum(b.karma)}</td>
|
|
1925
|
+
</tr>
|
|
1926
|
+
`;
|
|
1927
|
+
}).join('');
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// Reflect the active sort on every sortable header. Only one column
|
|
1931
|
+
// shows its arrow at a time; the others reset to "none" — matches the
|
|
1932
|
+
// ARIA-sort spec.
|
|
1933
|
+
for (const [key, headerId] of Object.entries(LB_SORT_HEADERS)) {
|
|
1934
|
+
const head = document.getElementById(headerId);
|
|
1935
|
+
if (!head) continue;
|
|
1936
|
+
const ariaSort = (lbState.sortBy === key && lbState.sortDir)
|
|
1937
|
+
? (lbState.sortDir === 'asc' ? 'ascending' : 'descending')
|
|
1938
|
+
: 'none';
|
|
1939
|
+
head.setAttribute('aria-sort', ariaSort);
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
$('#lb-range').textContent = rows.length
|
|
1943
|
+
? `${start + 1}–${Math.min(end, rows.length)} of ${rows.length}`
|
|
1944
|
+
: '0';
|
|
1945
|
+
$('#lb-prev').disabled = lbState.page === 0;
|
|
1946
|
+
$('#lb-next').disabled = end >= rows.length;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// Cycle: pick column → desc → asc → back to default (drachmae desc).
|
|
1950
|
+
// For a click on the active column we just flip direction; for a click on
|
|
1951
|
+
// a different column we activate it at 'desc' (the natural "best first"
|
|
1952
|
+
// direction for all three columns). Clicking the same column a third time
|
|
1953
|
+
// returns to the default drachmae-desc — discoverable, no dead state.
|
|
1954
|
+
function onLbSortClick(key) {
|
|
1955
|
+
if (lbState.sortBy === key) {
|
|
1956
|
+
if (lbState.sortDir === 'desc') {
|
|
1957
|
+
lbState.sortDir = 'asc';
|
|
1958
|
+
} else {
|
|
1959
|
+
// Third click returns to the default view.
|
|
1960
|
+
lbState.sortBy = 'drachmae';
|
|
1961
|
+
lbState.sortDir = 'desc';
|
|
1962
|
+
}
|
|
1963
|
+
} else {
|
|
1964
|
+
lbState.sortBy = key;
|
|
1965
|
+
lbState.sortDir = 'desc';
|
|
1966
|
+
}
|
|
1967
|
+
lbState.page = 0;
|
|
1968
|
+
renderLeaderboard();
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// ---- Archon security watch (V3.R66 / #285) -------------------------------
|
|
1972
|
+
//
|
|
1973
|
+
// Hidden by default; revealed only when /me returns rank='archon'. The
|
|
1974
|
+
// table renders open vulnerability reports + adjudication actions
|
|
1975
|
+
// (confirm / dispute / fix / link-fix). The audit log is collapsed
|
|
1976
|
+
// behind a <details> and loads on first open.
|
|
1977
|
+
|
|
1978
|
+
const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
1979
|
+
|
|
1980
|
+
let securityState = {
|
|
1981
|
+
statusFilter: '',
|
|
1982
|
+
auditLoaded: false,
|
|
1983
|
+
};
|
|
1984
|
+
|
|
1985
|
+
function severityBadge(sev) {
|
|
1986
|
+
const cls = `sev-${sev}`;
|
|
1987
|
+
return `<span class="sev-badge ${cls}">${escapeHtml(sev || '?')}</span>`;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
function renderSecurityReports(payload) {
|
|
1991
|
+
const tbody = document.querySelector('#security-reports tbody');
|
|
1992
|
+
if (!tbody) return;
|
|
1993
|
+
const reports = Array.isArray(payload?.reports) ? payload.reports : [];
|
|
1994
|
+
const bounty = payload?.bounty_table || {};
|
|
1995
|
+
if (reports.length === 0) {
|
|
1996
|
+
tbody.innerHTML = emptyRow(7, 'No reports match this filter.');
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
// Sort: open first (by severity desc), then everything else newest first.
|
|
2000
|
+
const sorted = reports.slice().sort((a, b) => {
|
|
2001
|
+
const aOpen = a.status === 'open' || a.status === 'repro_attempted' ? 1 : 0;
|
|
2002
|
+
const bOpen = b.status === 'open' || b.status === 'repro_attempted' ? 1 : 0;
|
|
2003
|
+
if (aOpen !== bOpen) return bOpen - aOpen;
|
|
2004
|
+
const aS = SEVERITY_RANK[a.severity] || 0;
|
|
2005
|
+
const bS = SEVERITY_RANK[b.severity] || 0;
|
|
2006
|
+
if (aS !== bS) return bS - aS;
|
|
2007
|
+
return new Date(b.reported_at) - new Date(a.reported_at);
|
|
2008
|
+
});
|
|
2009
|
+
tbody.innerHTML = sorted.map((r) => {
|
|
2010
|
+
const reporter = r.reporter_builder_id ? `#${escapeHtml(String(r.reporter_builder_id))}` : '—';
|
|
2011
|
+
const drachmae = r.drachmae_awarded != null ? fmtNum(r.drachmae_awarded) : (bounty[r.severity] ? `(${fmtNum(bounty[r.severity])} if confirmed)` : '—');
|
|
2012
|
+
const actions = renderSecurityActions(r);
|
|
2013
|
+
return `
|
|
2014
|
+
<tr data-report-id="${r.id}">
|
|
2015
|
+
<td class="ledger__num">${escapeHtml(String(r.id))}</td>
|
|
2016
|
+
<td>${severityBadge(r.severity)}</td>
|
|
2017
|
+
<td>${escapeHtml(r.title || '')}<div style="font-size:0.78rem;opacity:0.7;">${escapeHtml(r.target || '')}</div></td>
|
|
2018
|
+
<td>${escapeHtml(r.status || '')}</td>
|
|
2019
|
+
<td>${reporter}</td>
|
|
2020
|
+
<td class="ledger__drachmae">${drachmae}</td>
|
|
2021
|
+
<td>${actions}</td>
|
|
2022
|
+
</tr>
|
|
2023
|
+
`;
|
|
2024
|
+
}).join('');
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
function renderSecurityActions(report) {
|
|
2028
|
+
const id = report.id;
|
|
2029
|
+
const isTerminal = report.status === 'fixed' || report.status === 'wontfix';
|
|
2030
|
+
const buttons = [];
|
|
2031
|
+
if (!isTerminal && report.status !== 'confirmed') {
|
|
2032
|
+
buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="confirm" data-id="${id}">Confirm</button>`);
|
|
2033
|
+
}
|
|
2034
|
+
if (!isTerminal && report.status !== 'disputed') {
|
|
2035
|
+
buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="dispute" data-id="${id}">Dispute</button>`);
|
|
2036
|
+
}
|
|
2037
|
+
if (report.status !== 'fixed') {
|
|
2038
|
+
buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="fix" data-id="${id}">Mark fixed</button>`);
|
|
2039
|
+
}
|
|
2040
|
+
buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="link-fix" data-id="${id}" title="Attach a fix-task ID">Link fix…</button>`);
|
|
2041
|
+
return buttons.join(' ');
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
async function loadSecurityReports() {
|
|
2045
|
+
const status = securityState.statusFilter;
|
|
2046
|
+
const url = status ? `${API}/security/reports?status=${encodeURIComponent(status)}` : `${API}/security/reports`;
|
|
2047
|
+
try {
|
|
2048
|
+
const payload = await getJSON(url);
|
|
2049
|
+
renderSecurityReports(payload);
|
|
2050
|
+
} catch (err) {
|
|
2051
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
2052
|
+
const tbody = document.querySelector('#security-reports tbody');
|
|
2053
|
+
if (tbody) tbody.innerHTML = emptyRow(7, 'Could not load reports.');
|
|
2054
|
+
console.error('[builders] load security reports failed', err);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
async function adjudicateReport(id, action) {
|
|
2059
|
+
const status = $('#security-status');
|
|
2060
|
+
if (status) { status.hidden = false; status.textContent = 'Working…'; }
|
|
2061
|
+
try {
|
|
2062
|
+
let body = {};
|
|
2063
|
+
let path = `${API}/security/reports/${encodeURIComponent(id)}/${action}`;
|
|
2064
|
+
if (action === 'link-fix') {
|
|
2065
|
+
const taskIdRaw = window.prompt(`Task ID to link as the fix for report #${id}?`, '');
|
|
2066
|
+
if (taskIdRaw == null) {
|
|
2067
|
+
if (status) status.textContent = 'Cancelled.';
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
const taskId = Number(taskIdRaw);
|
|
2071
|
+
if (!Number.isInteger(taskId) || taskId <= 0) {
|
|
2072
|
+
if (status) status.textContent = 'Bad task id — must be a positive integer.';
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
body = { task_id: taskId };
|
|
2076
|
+
}
|
|
2077
|
+
const resp = await api.request('POST', path, { body });
|
|
2078
|
+
if (resp.status === 401) { window.location.assign(`${API}/auth/web/start`); throw new Error('redirecting to auth'); }
|
|
2079
|
+
const data = resp.data || {};
|
|
2080
|
+
if (!resp.ok) {
|
|
2081
|
+
if (status) status.textContent = `Failed: ${data.error || resp.status}`;
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
if (status) status.textContent = `Report #${id} → ${data.report?.status || action}`;
|
|
2085
|
+
await loadSecurityReports();
|
|
2086
|
+
} catch (err) {
|
|
2087
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
2088
|
+
console.error('[builders] adjudication failed', err);
|
|
2089
|
+
if (status) status.textContent = 'Adjudication failed. Try again.';
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
async function loadAuditLog() {
|
|
2094
|
+
const tbody = document.querySelector('#security-audit-log tbody');
|
|
2095
|
+
if (!tbody) return;
|
|
2096
|
+
tbody.innerHTML = emptyRow(5, 'Loading…');
|
|
2097
|
+
const method = $('#f-audit-method')?.value || '';
|
|
2098
|
+
const builderId = $('#f-audit-builder')?.value || '';
|
|
2099
|
+
const params = new URLSearchParams();
|
|
2100
|
+
params.set('limit', '50');
|
|
2101
|
+
if (method) params.set('method', method);
|
|
2102
|
+
if (builderId) params.set('builder_id', builderId);
|
|
2103
|
+
try {
|
|
2104
|
+
const payload = await getJSON(`${API}/audit-log?${params}`);
|
|
2105
|
+
const rows = Array.isArray(payload?.rows) ? payload.rows : [];
|
|
2106
|
+
if (rows.length === 0) {
|
|
2107
|
+
tbody.innerHTML = emptyRow(5, 'No matching audit entries.');
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
tbody.innerHTML = rows.map((r) => `
|
|
2111
|
+
<tr>
|
|
2112
|
+
<td>${escapeHtml(fmtDate(r.recorded_at))}</td>
|
|
2113
|
+
<td>#${escapeHtml(String(r.builder_id))}</td>
|
|
2114
|
+
<td>${escapeHtml(r.method)}</td>
|
|
2115
|
+
<td><code>${escapeHtml(r.route)}</code></td>
|
|
2116
|
+
<td>${escapeHtml(String(r.response_status))}</td>
|
|
2117
|
+
</tr>
|
|
2118
|
+
`).join('');
|
|
2119
|
+
} catch (err) {
|
|
2120
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
2121
|
+
console.error('[builders] audit-log load failed', err);
|
|
2122
|
+
tbody.innerHTML = emptyRow(5, 'Could not load audit log.');
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// task 1723: unhideArchonView is gone — the Archon nav group (Watch, Harbor,
|
|
2127
|
+
// Gate, Sessions) is the shell's, revealed by renderNav's applyAccess call
|
|
2128
|
+
// once /me confirms rank === 'archon' (shell.js gates Harbor on the dev-box
|
|
2129
|
+
// module too). The data still loads only on the Archon pages, each behind its
|
|
2130
|
+
// own archon-gated endpoint (#726).
|
|
2131
|
+
|
|
2132
|
+
// ---- Archon roster + management (V3.R14 / #238) --------------------------
|
|
2133
|
+
//
|
|
2134
|
+
// Archon-only roster of every builder, with promote / demote /
|
|
2135
|
+
// release-stale-claim controls. Hidden via #roster-scroll[hidden] for
|
|
2136
|
+
// non-Archons; unhideArchonView flips it on.
|
|
2137
|
+
//
|
|
2138
|
+
// Rank ladder is xenos < thetes < metic < archon (RANK_ORDER above). Promote
|
|
2139
|
+
// walks the ladder up ONE rung; demote walks it down one. The server enforces
|
|
2140
|
+
// the one-rung-promotion rule (#707), so the button must offer the true next
|
|
2141
|
+
// rung per row — a xenos promotes to thetes (not metic), a thetes to metic.
|
|
2142
|
+
|
|
2143
|
+
const ROSTER_LADDER = ['xenos', 'thetes', 'metic', 'archon'];
|
|
2144
|
+
// #408: paginate the Archon roster — it can grow past a screenful as the
|
|
2145
|
+
// community scales (200+ rows seen during stress test). 20 rows/page
|
|
2146
|
+
// matches the cardinality of "one comfortable scroll" inside the section
|
|
2147
|
+
// without forcing the user to scan further than they can hold in working
|
|
2148
|
+
// memory. Filter dropdowns still scope the dataset *before* pagination,
|
|
2149
|
+
// so a Show:onboarding filter still narrows to onboarding builders.
|
|
2150
|
+
const ROSTER_PAGE_SIZE = 20;
|
|
2151
|
+
|
|
2152
|
+
let rosterState = {
|
|
2153
|
+
rows: [],
|
|
2154
|
+
show: 'all',
|
|
2155
|
+
staleHours: 24,
|
|
2156
|
+
selfId: null,
|
|
2157
|
+
loading: false,
|
|
2158
|
+
page: 0,
|
|
2159
|
+
};
|
|
2160
|
+
|
|
2161
|
+
function nextRank(rank, direction /* 'up' | 'down' */) {
|
|
2162
|
+
const idx = ROSTER_LADDER.indexOf((rank || '').toLowerCase());
|
|
2163
|
+
if (idx < 0) return null;
|
|
2164
|
+
if (direction === 'up' && idx < ROSTER_LADDER.length - 1) return ROSTER_LADDER[idx + 1];
|
|
2165
|
+
if (direction === 'down' && idx > 0) return ROSTER_LADDER[idx - 1];
|
|
2166
|
+
return null;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function fmtTtfsMinutes(min) {
|
|
2170
|
+
if (min === null || min === undefined) return '—';
|
|
2171
|
+
const n = Number(min);
|
|
2172
|
+
if (!Number.isFinite(n) || n < 0) return '—';
|
|
2173
|
+
if (n < 60) return `${n}m`;
|
|
2174
|
+
const hours = n / 60;
|
|
2175
|
+
if (hours < 24) return `${hours.toFixed(1)}h`;
|
|
2176
|
+
const days = hours / 24;
|
|
2177
|
+
if (days < 30) return `${days.toFixed(1)}d`;
|
|
2178
|
+
const months = days / 30;
|
|
2179
|
+
return `${months.toFixed(1)}mo`;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function rosterFilterRows(rows) {
|
|
2183
|
+
switch (rosterState.show) {
|
|
2184
|
+
case 'onboarding':
|
|
2185
|
+
return rows.filter((r) => !r.first_ship_at && r.status !== 'inactive');
|
|
2186
|
+
case 'active':
|
|
2187
|
+
return rows.filter((r) => Number(r.active_claim_count) > 0);
|
|
2188
|
+
case 'stale':
|
|
2189
|
+
return rows.filter((r) => r.stale_claim);
|
|
2190
|
+
case 'inactive':
|
|
2191
|
+
return rows.filter((r) => r.status === 'inactive');
|
|
2192
|
+
case 'all':
|
|
2193
|
+
default:
|
|
2194
|
+
return rows;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
function renderRoster() {
|
|
2199
|
+
const tbody = document.querySelector('#roster-table tbody');
|
|
2200
|
+
const countEl = $('#roster-count');
|
|
2201
|
+
const pagerEl = $('#roster-pager');
|
|
2202
|
+
const rangeEl = $('#roster-range');
|
|
2203
|
+
const prevBtn = $('#roster-prev');
|
|
2204
|
+
const nextBtn = $('#roster-next');
|
|
2205
|
+
if (!tbody) return;
|
|
2206
|
+
const rows = rosterFilterRows(rosterState.rows);
|
|
2207
|
+
if (countEl) {
|
|
2208
|
+
countEl.textContent = `${rows.length} / ${rosterState.rows.length}`;
|
|
2209
|
+
}
|
|
2210
|
+
if (rows.length === 0) {
|
|
2211
|
+
tbody.innerHTML = emptyRow(9, 'No builders match this filter.');
|
|
2212
|
+
if (pagerEl) pagerEl.hidden = true;
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
// #408: client-side pagination. renderPager clamps the page index in case a
|
|
2216
|
+
// filter/refresh shrank the dataset under our feet (e.g. on page 4, then
|
|
2217
|
+
// switch to a filter with only 30 matching rows → snap back to the last
|
|
2218
|
+
// legal page) and updates the range + prev/next controls.
|
|
2219
|
+
const { start } = renderPager(rosterState, rows.length, ROSTER_PAGE_SIZE, { pagerEl, rangeEl, prevBtn, nextBtn });
|
|
2220
|
+
const slice = rows.slice(start, start + ROSTER_PAGE_SIZE);
|
|
2221
|
+
|
|
2222
|
+
tbody.innerHTML = slice.map((b) => {
|
|
2223
|
+
const isSelf = String(b.id) === String(rosterState.selfId);
|
|
2224
|
+
const isInactive = b.status === 'inactive';
|
|
2225
|
+
const upRank = nextRank(b.rank, 'up');
|
|
2226
|
+
const downRank = nextRank(b.rank, 'down');
|
|
2227
|
+
const claimCount = Number(b.active_claim_count) || 0;
|
|
2228
|
+
const stale = !!b.stale_claim;
|
|
2229
|
+
const claimsCell = claimCount === 0
|
|
2230
|
+
? '<span style="opacity:0.6;">none</span>'
|
|
2231
|
+
: `${claimCount}${stale ? ' <span class="roster__stale" title="At least one claim is stale (no pulse beyond threshold)">⚠ stale</span>' : ''}`;
|
|
2232
|
+
const actions = [];
|
|
2233
|
+
if (isSelf) {
|
|
2234
|
+
actions.push('<span style="opacity:0.55;font-size:0.8rem;">— self —</span>');
|
|
2235
|
+
} else if (isInactive) {
|
|
2236
|
+
actions.push('<span style="opacity:0.55;font-size:0.8rem;">inactive</span>');
|
|
2237
|
+
} else {
|
|
2238
|
+
if (upRank) {
|
|
2239
|
+
actions.push(`<button type="button" class="btn-ghost roster__promote" data-id="${escapeHtml(String(b.id))}" data-current="${escapeHtml(b.rank || '')}" data-next="${upRank}">Promote → ${upRank}</button>`);
|
|
2240
|
+
}
|
|
2241
|
+
if (downRank) {
|
|
2242
|
+
actions.push(`<button type="button" class="btn-ghost roster__demote" data-id="${escapeHtml(String(b.id))}" data-current="${escapeHtml(b.rank || '')}" data-next="${downRank}">Demote → ${downRank}</button>`);
|
|
2243
|
+
}
|
|
2244
|
+
if (stale) {
|
|
2245
|
+
actions.push(`<button type="button" class="btn-ghost roster__release-stale" data-id="${escapeHtml(String(b.id))}">Release stale claim</button>`);
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
const inactiveTag = isInactive ? ' <span class="roster__inactive">inactive</span>' : '';
|
|
2249
|
+
const selfTag = isSelf ? ' <span class="roster__self">you</span>' : '';
|
|
2250
|
+
const handle = b.display_name || b.github_login || `builder #${b.id}`;
|
|
2251
|
+
return `
|
|
2252
|
+
<tr data-builder-id="${escapeHtml(String(b.id))}">
|
|
2253
|
+
<td>${escapeHtml(handle)}${selfTag}${inactiveTag}<div style="font-size:0.75rem;opacity:0.65;">#${escapeHtml(String(b.id))} · @${escapeHtml(b.github_login || '')}</div></td>
|
|
2254
|
+
<td>${renderRankBadge(b.rank)}</td>
|
|
2255
|
+
<td class="ledger__drachmae">${fmtNum(b.total_credits)}</td>
|
|
2256
|
+
<td>${fmtNum(b.karma)}</td>
|
|
2257
|
+
<td class="ledger__date">${escapeHtml(fmtDate(b.onboarded_at))}</td>
|
|
2258
|
+
<td class="ledger__date">${b.first_ship_at ? escapeHtml(fmtDate(b.first_ship_at)) : '<span style="opacity:0.6;">— not yet —</span>'}</td>
|
|
2259
|
+
<td>${fmtTtfsMinutes(b.time_to_first_ship_minutes)}</td>
|
|
2260
|
+
<td>${claimsCell}</td>
|
|
2261
|
+
<td>${actions.join(' ')}</td>
|
|
2262
|
+
</tr>
|
|
2263
|
+
`;
|
|
2264
|
+
}).join('');
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
async function loadRoster() {
|
|
2268
|
+
const status = $('#roster-status');
|
|
2269
|
+
if (rosterState.loading) return;
|
|
2270
|
+
rosterState.loading = true;
|
|
2271
|
+
if (status) { status.hidden = false; status.textContent = 'Loading roster…'; }
|
|
2272
|
+
try {
|
|
2273
|
+
const payload = await getJSON(`${API}/builders/roster?stale_hours=${encodeURIComponent(rosterState.staleHours)}`);
|
|
2274
|
+
rosterState.rows = Array.isArray(payload?.builders) ? payload.builders : [];
|
|
2275
|
+
renderRoster();
|
|
2276
|
+
if (status) status.hidden = true;
|
|
2277
|
+
} catch (err) {
|
|
2278
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
2279
|
+
console.error('[builders] load roster failed', err);
|
|
2280
|
+
const tbody = document.querySelector('#roster-table tbody');
|
|
2281
|
+
if (tbody) tbody.innerHTML = emptyRow(9, 'Could not load the roster.');
|
|
2282
|
+
if (status) { status.hidden = false; status.textContent = `Could not load roster: ${err.message}`; }
|
|
2283
|
+
} finally {
|
|
2284
|
+
rosterState.loading = false;
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
async function rosterChangeRank(builderId, nextRankName, currentRankName) {
|
|
2289
|
+
const status = $('#roster-status');
|
|
2290
|
+
const ok = window.confirm(
|
|
2291
|
+
`Change builder #${builderId} from ${currentRankName || '?'} to ${nextRankName}?`
|
|
2292
|
+
);
|
|
2293
|
+
if (!ok) return;
|
|
2294
|
+
if (status) { status.hidden = false; status.textContent = `Setting rank to ${nextRankName}…`; }
|
|
2295
|
+
try {
|
|
2296
|
+
const resp = await patchJSON(`${API}/builders/${encodeURIComponent(builderId)}/rank`, { rank: nextRankName });
|
|
2297
|
+
const newRank = resp?.builder?.rank || nextRankName;
|
|
2298
|
+
if (status) status.textContent = `Builder #${builderId} → ${newRank}.`;
|
|
2299
|
+
toast(`Builder #${builderId} is now ${newRank}.`);
|
|
2300
|
+
await loadRoster();
|
|
2301
|
+
} catch (err) {
|
|
2302
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
2303
|
+
console.error('[builders] rank change failed', err);
|
|
2304
|
+
if (status) { status.hidden = false; status.textContent = `Rank change failed: ${err.message}`; }
|
|
2305
|
+
toast('Rank change failed.');
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
async function rosterReleaseStale(builderId) {
|
|
2310
|
+
const status = $('#roster-status');
|
|
2311
|
+
let claims;
|
|
2312
|
+
if (status) { status.hidden = false; status.textContent = 'Looking up stale claims…'; }
|
|
2313
|
+
try {
|
|
2314
|
+
const payload = await getJSON(`${API}/builders/${encodeURIComponent(builderId)}/active-claims?stale_hours=${encodeURIComponent(rosterState.staleHours)}`);
|
|
2315
|
+
claims = Array.isArray(payload?.claims) ? payload.claims : [];
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
2318
|
+
if (status) status.textContent = `Could not fetch claims: ${err.message}`;
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
const stale = claims.filter((c) => c.stale_claim);
|
|
2322
|
+
if (stale.length === 0) {
|
|
2323
|
+
if (status) status.textContent = 'No stale claims left to release for that builder.';
|
|
2324
|
+
// The roster row was probably stale-from-cache — refresh.
|
|
2325
|
+
await loadRoster();
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
// Pick the oldest stale claim (the queue order matches claimed_at ASC
|
|
2329
|
+
// from the server). For multiple stale claims, surface the count so
|
|
2330
|
+
// the Archon knows another action may be needed.
|
|
2331
|
+
const target = stale[0];
|
|
2332
|
+
const reason = window.prompt(
|
|
2333
|
+
`Release stale claim #${target.claim_id} (task #${target.task_id} "${target.task_title || ''}") for builder #${builderId}?\n\nReason (required):`,
|
|
2334
|
+
'pulse heartbeat went silent; freeing the task'
|
|
2335
|
+
);
|
|
2336
|
+
if (reason == null) {
|
|
2337
|
+
if (status) status.textContent = 'Cancelled.';
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
const trimmed = String(reason).trim();
|
|
2341
|
+
if (!trimmed) {
|
|
2342
|
+
if (status) status.textContent = 'A reason is required for an on-behalf release.';
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
if (status) status.textContent = `Releasing claim #${target.claim_id}…`;
|
|
2346
|
+
try {
|
|
2347
|
+
const resp = await api.request('POST', `${API}/claims/${encodeURIComponent(target.claim_id)}/release-on-behalf`, { body: { reason: trimmed } });
|
|
2348
|
+
if (resp.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
|
|
2349
|
+
const data = resp.data || {};
|
|
2350
|
+
if (!resp.ok) {
|
|
2351
|
+
if (status) status.textContent = `Release failed: ${data.error || resp.status}`;
|
|
2352
|
+
toast('Release failed.');
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
const remaining = stale.length - 1;
|
|
2356
|
+
if (status) status.textContent = `Released claim #${target.claim_id}.` + (remaining > 0 ? ` ${remaining} more stale claim(s) on this builder — click again to release the next.` : '');
|
|
2357
|
+
toast('Claim released.');
|
|
2358
|
+
await loadRoster();
|
|
2359
|
+
} catch (err) {
|
|
2360
|
+
if (status) status.textContent = `Release failed: ${err.message}`;
|
|
2361
|
+
toast('Release failed.');
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// ---- override requests (V3.R74 / #293) -----------------------------------
|
|
2366
|
+
//
|
|
2367
|
+
// Archon-only. Lists open requests where a builder asked the Archon to
|
|
2368
|
+
// override a failed grader. Approve flips task to confirmed + pays the
|
|
2369
|
+
// requester; deny only stamps the decision row.
|
|
2370
|
+
|
|
2371
|
+
async function loadOverrideRequests() {
|
|
2372
|
+
const tbody = document.querySelector('#override-requests tbody');
|
|
2373
|
+
if (!tbody) return;
|
|
2374
|
+
try {
|
|
2375
|
+
const data = await getJSON(`${API}/override-requests?status=open`);
|
|
2376
|
+
const rows = Array.isArray(data?.requests) ? data.requests : [];
|
|
2377
|
+
if (rows.length === 0) {
|
|
2378
|
+
tbody.innerHTML = emptyRow(6, 'No open requests.');
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
tbody.innerHTML = rows.map((r) => `
|
|
2382
|
+
<tr data-request-id="${escapeHtml(String(r.id))}">
|
|
2383
|
+
<td>${escapeHtml(String(r.id))}</td>
|
|
2384
|
+
<td>#${escapeHtml(String(r.task_id))} — ${escapeHtml(r.task_title || '')}</td>
|
|
2385
|
+
<td>@${escapeHtml(r.requester_login || '')}</td>
|
|
2386
|
+
<td class="override__rationale">${escapeHtml(r.rationale || '')}</td>
|
|
2387
|
+
<td>${escapeHtml(String(r.credits_reward ?? '—'))}</td>
|
|
2388
|
+
<td>
|
|
2389
|
+
<button class="btn-ghost override__approve" type="button">Approve</button>
|
|
2390
|
+
<button class="btn-ghost override__deny" type="button">Deny</button>
|
|
2391
|
+
</td>
|
|
2392
|
+
</tr>
|
|
2393
|
+
`).join('');
|
|
2394
|
+
} catch (err) {
|
|
2395
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
2396
|
+
console.error('[builders] override-requests failed', err);
|
|
2397
|
+
tbody.innerHTML = emptyRow(6, 'Could not load override requests.');
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
async function decideOverride(requestId, decision) {
|
|
2402
|
+
const status = $('#override-status');
|
|
2403
|
+
const verb = decision === 'approve' ? 'Approve' : 'Deny';
|
|
2404
|
+
const note = window.prompt(`${verb} request #${requestId} — optional note (sent to audit log):`, '');
|
|
2405
|
+
if (note === null) return; // user cancelled
|
|
2406
|
+
try {
|
|
2407
|
+
const res = await api.request('POST', `${API}/override-requests/${encodeURIComponent(requestId)}/decide`, { body: { decision, note: note || undefined } });
|
|
2408
|
+
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
|
|
2409
|
+
const body = res.data || {};
|
|
2410
|
+
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
|
|
2411
|
+
if (status) {
|
|
2412
|
+
status.hidden = false;
|
|
2413
|
+
status.textContent = decision === 'approve'
|
|
2414
|
+
? `Approved — task flipped to confirmed, ${curLabel()} paid: ${body.confirmResult?.creditsAwarded ?? 0}.`
|
|
2415
|
+
: `Denied — the task stays at completed.`;
|
|
2416
|
+
}
|
|
2417
|
+
await loadOverrideRequests();
|
|
2418
|
+
} catch (err) {
|
|
2419
|
+
console.error('[builders] decide override failed', err);
|
|
2420
|
+
if (status) { status.hidden = false; status.textContent = `Could not decide: ${err.message}`; }
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
// ---- skill model preferences (V3.R85 / #304) -----------------------------
|
|
2425
|
+
//
|
|
2426
|
+
// The matrix lets each builder override the system default model for each
|
|
2427
|
+
// slash-command skill. System defaults come from the audit in
|
|
2428
|
+
// src/bongos/skill-prefs.js (mechanism vs judgment classification) and are
|
|
2429
|
+
// echoed back in the GET response so the table can label them.
|
|
2430
|
+
|
|
2431
|
+
const MODEL_LABELS = {
|
|
2432
|
+
opus: 'Opus',
|
|
2433
|
+
sonnet: 'Sonnet',
|
|
2434
|
+
haiku: 'Haiku',
|
|
2435
|
+
script: 'no LLM',
|
|
2436
|
+
};
|
|
2437
|
+
|
|
2438
|
+
function modelLabel(m) {
|
|
2439
|
+
return MODEL_LABELS[m] || m || '—';
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
function renderSkillPrefs(data) {
|
|
2443
|
+
const tbody = $('#skill-prefs-table tbody');
|
|
2444
|
+
if (!tbody) return;
|
|
2445
|
+
const rows = Array.isArray(data?.effective) ? data.effective : [];
|
|
2446
|
+
const allowed = Array.isArray(data?.allowed_models) ? data.allowed_models : ['opus', 'sonnet', 'haiku', 'script'];
|
|
2447
|
+
if (rows.length === 0) {
|
|
2448
|
+
tbody.innerHTML = emptyRow(3, 'No skills registered.');
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
tbody.innerHTML = rows.map((row) => {
|
|
2452
|
+
const opts = ['<option value="">— default —</option>']
|
|
2453
|
+
.concat(allowed.map((m) => {
|
|
2454
|
+
const sel = row.override_model === m ? ' selected' : '';
|
|
2455
|
+
return `<option value="${m}"${sel}>${modelLabel(m)}</option>`;
|
|
2456
|
+
}))
|
|
2457
|
+
.join('');
|
|
2458
|
+
const defaultCell = `<span class="skill-prefs__default" title="System default for this skill">${modelLabel(row.default_model)}</span>`;
|
|
2459
|
+
return `
|
|
2460
|
+
<tr data-skill="${escapeHtml(row.skill)}">
|
|
2461
|
+
<td><code>${escapeHtml(row.skill)}</code></td>
|
|
2462
|
+
<td>${defaultCell}</td>
|
|
2463
|
+
<td>
|
|
2464
|
+
<select class="skill-prefs__select" data-skill="${escapeHtml(row.skill)}" aria-label="Model for ${escapeHtml(row.skill)}">
|
|
2465
|
+
${opts}
|
|
2466
|
+
</select>
|
|
2467
|
+
</td>
|
|
2468
|
+
</tr>
|
|
2469
|
+
`;
|
|
2470
|
+
}).join('');
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
async function saveSkillPref(skill, modelOrNull) {
|
|
2474
|
+
const status = $('#skill-prefs-status');
|
|
2475
|
+
if (status) { status.hidden = false; status.textContent = 'Saving…'; }
|
|
2476
|
+
try {
|
|
2477
|
+
const body = { [skill]: modelOrNull };
|
|
2478
|
+
const resp = await patchJSON(`${API}/me/skill-prefs`, body);
|
|
2479
|
+
renderSkillPrefs(resp);
|
|
2480
|
+
if (status) status.textContent = modelOrNull
|
|
2481
|
+
? `Set ${skill} → ${modelLabel(modelOrNull)}.`
|
|
2482
|
+
: `Cleared ${skill} override (falls back to default).`;
|
|
2483
|
+
} catch (err) {
|
|
2484
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
2485
|
+
console.error('[builders] save skill pref failed', err);
|
|
2486
|
+
if (status) status.textContent = 'Could not save. Try again.';
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
// ---- onboarding live-tick poll (task 1444) -------------------------------
|
|
2491
|
+
//
|
|
2492
|
+
// The onboarding checklist is rendered once from the page-load /me snapshot.
|
|
2493
|
+
// But three of its steps clear from LIVE server signals that flip WITHOUT any
|
|
2494
|
+
// local action or page reload while the hall sits open:
|
|
2495
|
+
// * box_connect — stamped by the box's heartbeat the first time a `claude`
|
|
2496
|
+
// process runs on the dev box (builder_boxes.first_connected_at);
|
|
2497
|
+
// * cli_setup — stamped when the GDS CLI is first authenticated on the box;
|
|
2498
|
+
// * join_discord — flips on the OAuth link (usually a full redirect, but can
|
|
2499
|
+
// also land from another tab).
|
|
2500
|
+
// Without a poll, a newcomer who connects their box AFTER opening the hall sees
|
|
2501
|
+
// the step stay unchecked until they manually reload — exactly the bug scootermack
|
|
2502
|
+
// hit. "The city watches" only worked on the next full reload. So while any
|
|
2503
|
+
// live-signal step is still uncleared, re-fetch /me on an interval and re-render
|
|
2504
|
+
// the checklist; stop once they all clear (or the builder graduates). The box
|
|
2505
|
+
// panel runs its OWN poll and is never re-mounted here (renderOnboarding touches
|
|
2506
|
+
// a different DOM subtree), so this doesn't disturb an in-flight setup.
|
|
2507
|
+
let onboardingPollTimer = null;
|
|
2508
|
+
const ONBOARDING_POLL_MS = 15000;
|
|
2509
|
+
|
|
2510
|
+
// True while at least one step that clears from a live signal (box/cli/discord)
|
|
2511
|
+
// is still uncleared and the builder hasn't graduated. The pick_disciplines and
|
|
2512
|
+
// ship_three steps flip via local action (PATCH re-renders) or shipping, so they
|
|
2513
|
+
// don't need a poll — scoping to the live-signal steps bounds polling to the
|
|
2514
|
+
// setup window rather than running forever for any non-graduated builder.
|
|
2515
|
+
function onboardingNeedsLivePoll(onboarding) {
|
|
2516
|
+
if (!onboarding || onboarding.graduated) return false;
|
|
2517
|
+
const stages = Array.isArray(onboarding.stages) ? onboarding.stages : [];
|
|
2518
|
+
return stages.some((s) =>
|
|
2519
|
+
(s.source === 'box' || s.source === 'cli' || s.source === 'discord') && !s.cleared);
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
function stopOnboardingPoll() {
|
|
2523
|
+
if (onboardingPollTimer) { clearTimeout(onboardingPollTimer); onboardingPollTimer = null; }
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
function scheduleOnboardingPoll(onboarding) {
|
|
2527
|
+
stopOnboardingPoll();
|
|
2528
|
+
if (!onboardingNeedsLivePoll(onboarding)) return;
|
|
2529
|
+
onboardingPollTimer = setTimeout(refreshOnboardingFromServer, ONBOARDING_POLL_MS);
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
async function refreshOnboardingFromServer() {
|
|
2533
|
+
onboardingPollTimer = null;
|
|
2534
|
+
// Don't poll a backgrounded tab — wait for it to come back (the
|
|
2535
|
+
// visibilitychange listener re-arms from the cached state).
|
|
2536
|
+
if (document.hidden) { scheduleOnboardingPoll(lastMeState && lastMeState.onboarding); return; }
|
|
2537
|
+
let me;
|
|
2538
|
+
try {
|
|
2539
|
+
me = await getJSON(`${API}/me`, { softAuth: true });
|
|
2540
|
+
} catch (err) {
|
|
2541
|
+
// Session expired → stop (a redirect/landing will take over on next action).
|
|
2542
|
+
if (err && err.code === AUTH_NEEDED) { stopOnboardingPoll(); return; }
|
|
2543
|
+
// Transient (network blip / 5xx) → try again next interval so a single
|
|
2544
|
+
// failure doesn't permanently kill the auto-tick.
|
|
2545
|
+
scheduleOnboardingPoll(lastMeState && lastMeState.onboarding);
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2548
|
+
if (!me || !me.builder) return;
|
|
2549
|
+
const wasGraduated = !!(lastMeState && lastMeState.onboarding && lastMeState.onboarding.graduated);
|
|
2550
|
+
lastMeState = me;
|
|
2551
|
+
lastKarmaState = me.karma || lastKarmaState;
|
|
2552
|
+
lastCostState = me.cost || lastCostState;
|
|
2553
|
+
lastNeedsState = me.needs || lastNeedsState;
|
|
2554
|
+
renderOnboarding(me.onboarding || null, me.discord);
|
|
2555
|
+
// If they graduated while watching, hide the welcome panel + rebuild the nav
|
|
2556
|
+
// (drops the Welcome/Path chips) — the same transition loadAll would render.
|
|
2557
|
+
if (me.onboarding && me.onboarding.graduated && !wasGraduated) {
|
|
2558
|
+
renderWelcome(me.builder, me.onboarding);
|
|
2559
|
+
renderNav(me.builder, me.onboarding || null);
|
|
2560
|
+
}
|
|
2561
|
+
scheduleOnboardingPoll(me.onboarding || null);
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// ---- bootstrap -----------------------------------------------------------
|
|
2565
|
+
|
|
2566
|
+
async function loadAll() {
|
|
2567
|
+
// Profile first. If /me returns 401, render an unauth landing card with a
|
|
2568
|
+
// Sign-in button instead of bouncing straight to GitHub — gives the visitor
|
|
2569
|
+
// a beat of orientation about what they're walking into.
|
|
2570
|
+
let me;
|
|
2571
|
+
try {
|
|
2572
|
+
me = await getJSON(`${API}/me`, { softAuth: true });
|
|
2573
|
+
} catch (err) {
|
|
2574
|
+
if (err && err.code === AUTH_NEEDED) { renderLanding(); return; }
|
|
2575
|
+
throw err;
|
|
2576
|
+
}
|
|
2577
|
+
// Signed in: drop the landing card if a previous unauth render left it up.
|
|
2578
|
+
const landingRoot = $('#landing-root');
|
|
2579
|
+
if (landingRoot) { landingRoot.hidden = true; landingRoot.innerHTML = ''; }
|
|
2580
|
+
|
|
2581
|
+
lastKarmaState = me.karma || null;
|
|
2582
|
+
lastCostState = me.cost || null;
|
|
2583
|
+
lastNeedsState = me.needs || null; // task 1013 / ADR 0073: action-needed banner
|
|
2584
|
+
// #408: stash the whole /me payload so reopenWelcomeFromNav can re-render
|
|
2585
|
+
// the welcome panel without a fresh round-trip.
|
|
2586
|
+
lastMeState = me;
|
|
2587
|
+
// #748: decide the newcomer gate up front so the async karma/acclaim feeds
|
|
2588
|
+
// (which un-hide their own scroll on data arrival) honour it too.
|
|
2589
|
+
newcomerGated = normalizeRank(me.builder?.rank) === 'xenos';
|
|
2590
|
+
// V3.R77 (#296): first-visit welcome panel. Renders above the profile and
|
|
2591
|
+
// hides itself once dismissed or once the builder graduates. Computed
|
|
2592
|
+
// before profile so the dismiss button is wired by the time the user
|
|
2593
|
+
// sees the page.
|
|
2594
|
+
// Cache self id on window so the dismiss-click handler can find it
|
|
2595
|
+
// without re-fetching /me. Set BEFORE renderWelcome so the wired
|
|
2596
|
+
// dismiss listener (attached once at bootstrap) can use it immediately.
|
|
2597
|
+
window.__buildersSelfId = me.builder?.id ?? null;
|
|
2598
|
+
renderWelcome(me.builder, me.onboarding || null);
|
|
2599
|
+
renderProfile(me.builder);
|
|
2600
|
+
// #731: the CLI-token re-issue card (V3.R91 #401) and the Discord connect/
|
|
2601
|
+
// link card (F7, ADR 0033) moved to /builders/settings — the hall's Standing
|
|
2602
|
+
// card is identity-only now.
|
|
2603
|
+
// V3.R75 (#294): auto-completing onboarding checklist. The /me payload
|
|
2604
|
+
// carries the builder's own state directly so this is a render-only call.
|
|
2605
|
+
renderOnboarding(me.onboarding || null, me.discord);
|
|
2606
|
+
// task 1444: keep the checklist live — re-fetch /me on an interval while a
|
|
2607
|
+
// box/cli/discord step is still uncleared, so it ticks itself without a manual
|
|
2608
|
+
// reload the moment the dev box connects (or the CLI/Discord links).
|
|
2609
|
+
scheduleOnboardingPoll(me.onboarding || null);
|
|
2610
|
+
renderRecentGrades(me.grades?.recent || []);
|
|
2611
|
+
renderRollingNote(me.grades?.rolling || null);
|
|
2612
|
+
// V3.R72 (#291, C5): the dev curve. Loaded fire-and-forget — a slow or
|
|
2613
|
+
// empty analytics feed must not hold up the rest of the hall, and the
|
|
2614
|
+
// section un-hides itself (and re-renders the nav) only when there's data.
|
|
2615
|
+
loadAnalytics(me.builder);
|
|
2616
|
+
// V3.R26 (#246): karma feed — the recent karma_log entries powering the
|
|
2617
|
+
// "why did my karma move" answer. The /me payload bundles this so the
|
|
2618
|
+
// page doesn't need a follow-up call.
|
|
2619
|
+
renderKarmaFeed(me.karma?.recent || []);
|
|
2620
|
+
// V3.R25 (#245): peer acclaim feed — a separate endpoint (not bundled in
|
|
2621
|
+
// /me because it spans all builders' work), loaded fire-and-forget so a
|
|
2622
|
+
// slow/failed feed can't hold up the rest of the hall.
|
|
2623
|
+
loadAcclaimFeed();
|
|
2624
|
+
// Capture self id for the roster's "cannot change your own rank" guard.
|
|
2625
|
+
rosterState.selfId = me.builder?.id ?? null;
|
|
2626
|
+
|
|
2627
|
+
// #408: build the sticky nav now that every section has settled its
|
|
2628
|
+
// hidden state (welcome → karma → onboarding → security/roster).
|
|
2629
|
+
// The renderNav call also wires the IntersectionObserver for active-
|
|
2630
|
+
// section highlighting. Idempotent — safe to call again on dismiss/re-open.
|
|
2631
|
+
// #748: collapse the hall to the newcomer view (onboarding + Standing) for
|
|
2632
|
+
// a Xenos BEFORE building the nav, so the chip strip matches what's shown.
|
|
2633
|
+
applyNewcomerGate();
|
|
2634
|
+
renderNav(me.builder, me.onboarding || null);
|
|
2635
|
+
|
|
2636
|
+
// Then the rest, in parallel. Any individual section failure shouldn't take
|
|
2637
|
+
// down the page — log and show an inline placeholder.
|
|
2638
|
+
// #726: the per-skill model picker (Voices) moved to /builders/settings,
|
|
2639
|
+
// so the hall no longer fetches /me/skill-prefs here.
|
|
2640
|
+
const [achAll, ships, versionsResp, lb, bounty] = await Promise.allSettled([
|
|
2641
|
+
getJSON(`${API}/achievements`),
|
|
2642
|
+
getJSON(`${API}/me/recent-ships?limit=all`),
|
|
2643
|
+
getJSON(`${API}/versions`),
|
|
2644
|
+
getJSON(`${API}/leaderboard`),
|
|
2645
|
+
getJSON(`${API}/public/bounty-table`),
|
|
2646
|
+
]);
|
|
2647
|
+
|
|
2648
|
+
if (achAll.status === 'fulfilled') {
|
|
2649
|
+
// Show top 10 by sort_order — already ordered server-side. (Set is locked at 8
|
|
2650
|
+
// for V2, so the slice is defensive.) Unlocked + locked-with-progress come
|
|
2651
|
+
// straight from the enriched /me payload.
|
|
2652
|
+
renderAchievements(
|
|
2653
|
+
(achAll.value.achievements || []).slice(0, 10),
|
|
2654
|
+
me.unlocked || [],
|
|
2655
|
+
me.locked_with_progress || [],
|
|
2656
|
+
);
|
|
2657
|
+
} else {
|
|
2658
|
+
$('#ach-grid').innerHTML = `<div class="profile__placeholder">Achievements could not be loaded.</div>`;
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
if (ships.status === 'fulfilled') {
|
|
2662
|
+
shipState.all = ships.value.ships || [];
|
|
2663
|
+
renderRecentShips();
|
|
2664
|
+
} else {
|
|
2665
|
+
$('#recent-ships tbody').innerHTML = emptyRow(4, 'Your work history could not be loaded.');
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
if (versionsResp.status === 'fulfilled') {
|
|
2669
|
+
const versions = versionsResp.value.versions || [];
|
|
2670
|
+
// Populate the Works Wrought version filter — limited to versions this
|
|
2671
|
+
// builder has actually shipped to, so the dropdown can't offer empty
|
|
2672
|
+
// options. (The old claimable-works version filter moved to /work, #750.)
|
|
2673
|
+
populateShipVersionFilter(versions);
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
if (lb.status === 'fulfilled') {
|
|
2677
|
+
lbState.rows = lb.value.leaderboard || [];
|
|
2678
|
+
lbState.page = 0;
|
|
2679
|
+
renderLeaderboard();
|
|
2680
|
+
// #757: the Standing card painted before the Roll loaded, so re-render it
|
|
2681
|
+
// now that lbState.rows exists — this is when its "#N on the Roll" stat
|
|
2682
|
+
// can resolve.
|
|
2683
|
+
if (lastMeState && lastMeState.builder) renderProfile(lastMeState.builder);
|
|
2684
|
+
} else {
|
|
2685
|
+
$('#leaderboard tbody').innerHTML = emptyRow(5, 'The leaderboard could not be loaded.');
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
if (bounty.status === 'fulfilled') {
|
|
2689
|
+
renderBountyTable(bounty.value.table || []);
|
|
2690
|
+
} else {
|
|
2691
|
+
$('#bounty-table tbody').innerHTML = emptyRow(3, 'The reward schedule could not be loaded.');
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
function wireEvents() {
|
|
2697
|
+
// #profile-body is re-rendered after load, so delegate the save click.
|
|
2698
|
+
$('#profile-body').addEventListener('click', (e) => {
|
|
2699
|
+
if (e.target.closest('#disc-save')) saveDisciplines();
|
|
2700
|
+
});
|
|
2701
|
+
|
|
2702
|
+
// V3.R72 (#291): the dev-curve scope toggle (Archon only). Delegated on the
|
|
2703
|
+
// curve body since renderCurveBody replaces the toggle's innerHTML.
|
|
2704
|
+
$('#curve-body')?.addEventListener('click', (e) => {
|
|
2705
|
+
const btn = e.target.closest('.curve__scope-btn[data-scope]');
|
|
2706
|
+
if (!btn) return;
|
|
2707
|
+
const scope = btn.dataset.scope === 'eco' ? 'eco' : 'self';
|
|
2708
|
+
if (scope === analyticsState.scope) return;
|
|
2709
|
+
analyticsState.scope = scope;
|
|
2710
|
+
renderCurveBody();
|
|
2711
|
+
});
|
|
2712
|
+
|
|
2713
|
+
// task 1723: the Home "show the setup guide again" link re-opens a dismissed
|
|
2714
|
+
// welcome banner (the old welcome chip's job) — clears the dismissal flag,
|
|
2715
|
+
// re-renders the panel, refreshes the nav, then scrolls to it.
|
|
2716
|
+
document.getElementById('welcome-reopen')?.addEventListener('click', (e) => {
|
|
2717
|
+
reopenWelcomeFromNav(e);
|
|
2718
|
+
});
|
|
2719
|
+
|
|
2720
|
+
// V3.R75 (#294): the onboarding list re-renders on each /me load, so the
|
|
2721
|
+
// inline craft picker is wired by delegation to the list container. (The
|
|
2722
|
+
// primer-read ack button that also lived here was removed in #766.)
|
|
2723
|
+
$('#onboarding-list')?.addEventListener('click', (e) => {
|
|
2724
|
+
const disc = e.target.closest('.onboarding-item__disc[data-disc]');
|
|
2725
|
+
if (disc) { pickDiscipline(disc.dataset.disc); return; }
|
|
2726
|
+
// #777: the only other control is the Connect-Discord anchor, which is a
|
|
2727
|
+
// real link (full-page nav into the OAuth flow) — nothing to handle here.
|
|
2728
|
+
// Both new steps (join_discord, cli_setup) auto-detect server-side.
|
|
2729
|
+
});
|
|
2730
|
+
|
|
2731
|
+
// #777: setup-config toggle in the welcome panel. Flips which config pane
|
|
2732
|
+
// (Desktop app vs Claude Online) is shown — pure client-side, no round-trip.
|
|
2733
|
+
// The dev-box buttons + credential-copy inside the online pane are handled by
|
|
2734
|
+
// the OTBBoxPanel module's own scoped listener (#807), not here.
|
|
2735
|
+
$('#welcome-body')?.addEventListener('click', (e) => {
|
|
2736
|
+
const tab = e.target.closest('.setup__tab[data-config]');
|
|
2737
|
+
if (!tab) return;
|
|
2738
|
+
const setup = tab.closest('.setup');
|
|
2739
|
+
if (!setup) return;
|
|
2740
|
+
setup.dataset.config = tab.dataset.config;
|
|
2741
|
+
setup.querySelectorAll('.setup__tab').forEach((t) => {
|
|
2742
|
+
t.setAttribute('aria-selected', String(t === tab));
|
|
2743
|
+
});
|
|
2744
|
+
writeSetupConfig(tab.dataset.config); // #785: remember the choice
|
|
2745
|
+
});
|
|
2746
|
+
|
|
2747
|
+
// Welcome panel minimize toggle (replaces full dismiss).
|
|
2748
|
+
$('#welcome-minimize')?.addEventListener('click', () => {
|
|
2749
|
+
const scroll = $('#welcome-scroll');
|
|
2750
|
+
const btn = $('#welcome-minimize');
|
|
2751
|
+
if (!scroll) return;
|
|
2752
|
+
const minimized = scroll.classList.toggle('scroll--welcome--minimized');
|
|
2753
|
+
if (btn) btn.textContent = minimized ? '+' : '−';
|
|
2754
|
+
if (minimized) onboardingBox().stop(); else onboardingBox().refresh();
|
|
2755
|
+
});
|
|
2756
|
+
|
|
2757
|
+
// V3.R25 (#245): delegated click handler for the peer-acclaim vote buttons.
|
|
2758
|
+
$('#acclaim-feed')?.addEventListener('click', onAcclaimClick);
|
|
2759
|
+
|
|
2760
|
+
$('#f-ships-window')?.addEventListener('change', onShipsFilterChange);
|
|
2761
|
+
$('#f-ships-version')?.addEventListener('change', onShipsFilterChange);
|
|
2762
|
+
$('#f-ships-sort')?.addEventListener('change', renderRecentShips);
|
|
2763
|
+
$('#ships-prev')?.addEventListener('click', () => {
|
|
2764
|
+
if (shipState.page > 0) { shipState.page--; renderRecentShips(); }
|
|
2765
|
+
});
|
|
2766
|
+
$('#ships-next')?.addEventListener('click', () => {
|
|
2767
|
+
shipState.page++;
|
|
2768
|
+
renderRecentShips();
|
|
2769
|
+
});
|
|
2770
|
+
|
|
2771
|
+
|
|
2772
|
+
$('#lb-prev').addEventListener('click', () => {
|
|
2773
|
+
if (lbState.page > 0) { lbState.page--; renderLeaderboard(); }
|
|
2774
|
+
});
|
|
2775
|
+
$('#lb-next').addEventListener('click', () => {
|
|
2776
|
+
const max = Math.ceil(lbState.rows.length / lbState.pageSize) - 1;
|
|
2777
|
+
if (lbState.page < max) { lbState.page++; renderLeaderboard(); }
|
|
2778
|
+
});
|
|
2779
|
+
|
|
2780
|
+
// Sortable leaderboard headers — clickable + keyboard-operable. Each
|
|
2781
|
+
// sortable column registers under LB_SORT_HEADERS; the click handler
|
|
2782
|
+
// routes the column key into the shared cycle.
|
|
2783
|
+
for (const [key, headerId] of Object.entries(LB_SORT_HEADERS)) {
|
|
2784
|
+
const head = document.getElementById(headerId);
|
|
2785
|
+
if (!head) continue;
|
|
2786
|
+
head.setAttribute('tabindex', '0');
|
|
2787
|
+
head.setAttribute('role', 'button');
|
|
2788
|
+
head.addEventListener('click', () => onLbSortClick(key));
|
|
2789
|
+
head.addEventListener('keydown', (e) => {
|
|
2790
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
2791
|
+
e.preventDefault();
|
|
2792
|
+
onLbSortClick(key);
|
|
2793
|
+
}
|
|
2794
|
+
});
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
// Security watch (Archon-only) — status filter + delegated action clicks.
|
|
2798
|
+
// The section is hidden for non-Archons via unhideArchonView, so these
|
|
2799
|
+
// listeners are safe to attach unconditionally (they just won't fire).
|
|
2800
|
+
$('#f-security-status')?.addEventListener('change', (e) => {
|
|
2801
|
+
securityState.statusFilter = e.target.value || '';
|
|
2802
|
+
loadSecurityReports();
|
|
2803
|
+
});
|
|
2804
|
+
$('#security-reports')?.addEventListener('click', (e) => {
|
|
2805
|
+
const btn = e.target.closest('.sec-act');
|
|
2806
|
+
if (!btn) return;
|
|
2807
|
+
const id = btn.dataset.id;
|
|
2808
|
+
const action = btn.dataset.action;
|
|
2809
|
+
if (id && action) adjudicateReport(id, action);
|
|
2810
|
+
});
|
|
2811
|
+
// Override-requests approve/deny — delegated to the table body.
|
|
2812
|
+
$('#override-requests')?.addEventListener('click', (e) => {
|
|
2813
|
+
const tr = e.target.closest('tr[data-request-id]');
|
|
2814
|
+
if (!tr) return;
|
|
2815
|
+
const id = tr.dataset.requestId;
|
|
2816
|
+
if (e.target.classList.contains('override__approve')) decideOverride(id, 'approve');
|
|
2817
|
+
else if (e.target.classList.contains('override__deny')) decideOverride(id, 'deny');
|
|
2818
|
+
});
|
|
2819
|
+
// Audit log — lazy-load on first <details> open, refresh on filter change.
|
|
2820
|
+
$('#security-audit-details')?.addEventListener('toggle', (e) => {
|
|
2821
|
+
if (e.target.open && !securityState.auditLoaded) {
|
|
2822
|
+
securityState.auditLoaded = true;
|
|
2823
|
+
loadAuditLog();
|
|
2824
|
+
}
|
|
2825
|
+
});
|
|
2826
|
+
$('#f-audit-method')?.addEventListener('change', loadAuditLog);
|
|
2827
|
+
$('#f-audit-builder')?.addEventListener('change', loadAuditLog);
|
|
2828
|
+
$('#audit-refresh')?.addEventListener('click', loadAuditLog);
|
|
2829
|
+
|
|
2830
|
+
// Skill-prefs table — change handler delegated so post-render select
|
|
2831
|
+
// elements still fire. The empty value ("— default —") clears the override
|
|
2832
|
+
// via the null sentinel; everything else PATCHes the named model.
|
|
2833
|
+
const prefsTable = $('#skill-prefs-table');
|
|
2834
|
+
if (prefsTable) {
|
|
2835
|
+
prefsTable.addEventListener('change', (e) => {
|
|
2836
|
+
const sel = e.target.closest('.skill-prefs__select');
|
|
2837
|
+
if (!sel) return;
|
|
2838
|
+
const skill = sel.dataset.skill;
|
|
2839
|
+
if (!skill) return;
|
|
2840
|
+
const next = sel.value === '' ? null : sel.value;
|
|
2841
|
+
saveSkillPref(skill, next);
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
// Roster — Archon-only. Filter change, threshold change, refresh button.
|
|
2846
|
+
// Action buttons (promote / demote / release-stale) delegated to the
|
|
2847
|
+
// table body; the table is re-rendered on every action so listeners can
|
|
2848
|
+
// safely be one-shot at the container.
|
|
2849
|
+
$('#f-roster-show')?.addEventListener('change', (e) => {
|
|
2850
|
+
rosterState.show = e.target.value || 'all';
|
|
2851
|
+
// #408: changing the filter changes the dataset, so jump back to the
|
|
2852
|
+
// first page — otherwise a "show: onboarding (3 rows)" filter could
|
|
2853
|
+
// leave the user stranded on page 4 of "all".
|
|
2854
|
+
rosterState.page = 0;
|
|
2855
|
+
renderRoster();
|
|
2856
|
+
});
|
|
2857
|
+
$('#f-roster-stale')?.addEventListener('change', (e) => {
|
|
2858
|
+
const n = Number(e.target.value);
|
|
2859
|
+
if (Number.isFinite(n) && n > 0 && n <= 720) {
|
|
2860
|
+
rosterState.staleHours = n;
|
|
2861
|
+
rosterState.page = 0;
|
|
2862
|
+
loadRoster();
|
|
2863
|
+
} else {
|
|
2864
|
+
e.target.value = String(rosterState.staleHours);
|
|
2865
|
+
toast('Stale threshold must be 1–720 hours.');
|
|
2866
|
+
}
|
|
2867
|
+
});
|
|
2868
|
+
$('#roster-refresh')?.addEventListener('click', loadRoster);
|
|
2869
|
+
// #408: roster pager — prev/next buttons mirroring the leaderboard
|
|
2870
|
+
// pattern. Page index is clamped inside renderRoster, so we don't need
|
|
2871
|
+
// to guard the bounds here.
|
|
2872
|
+
$('#roster-prev')?.addEventListener('click', () => {
|
|
2873
|
+
if (rosterState.page > 0) { rosterState.page--; renderRoster(); }
|
|
2874
|
+
});
|
|
2875
|
+
$('#roster-next')?.addEventListener('click', () => {
|
|
2876
|
+
rosterState.page++;
|
|
2877
|
+
renderRoster();
|
|
2878
|
+
});
|
|
2879
|
+
$('#roster-table')?.addEventListener('click', (e) => {
|
|
2880
|
+
const btn = e.target.closest('button');
|
|
2881
|
+
if (!btn) return;
|
|
2882
|
+
const id = btn.dataset.id;
|
|
2883
|
+
if (!id) return;
|
|
2884
|
+
if (btn.classList.contains('roster__promote') || btn.classList.contains('roster__demote')) {
|
|
2885
|
+
rosterChangeRank(id, btn.dataset.next, btn.dataset.current);
|
|
2886
|
+
} else if (btn.classList.contains('roster__release-stale')) {
|
|
2887
|
+
rosterReleaseStale(id);
|
|
2888
|
+
}
|
|
2889
|
+
});
|
|
2890
|
+
|
|
2891
|
+
// Logout link: the /api/bongos/auth/logout endpoint is POST + requires the
|
|
2892
|
+
// session cookie. Convert the anchor click into a fetch then bounce home.
|
|
2893
|
+
$('#logout-link').addEventListener('click', async (e) => {
|
|
2894
|
+
e.preventDefault();
|
|
2895
|
+
try {
|
|
2896
|
+
await api.request('POST', `${API}/auth/logout`);
|
|
2897
|
+
} catch (_) { /* even if it fails we drop the cookie below */ }
|
|
2898
|
+
// Best-effort cookie clear on the client side too. Mirror the SERVER
|
|
2899
|
+
// logout (src/bongos/routes/auth.js): expire BOTH the current `gds_session`
|
|
2900
|
+
// and the legacy `pms_session`, each in BOTH the host-only form and the
|
|
2901
|
+
// apex-scoped `Domain=.amazonprimea.com` form (#726 — the session cookie
|
|
2902
|
+
// is set apex-scoped on production hosts so it crosses subdomains; an
|
|
2903
|
+
// expire only matches the cookie whose Domain attribute it names, so the
|
|
2904
|
+
// host-only Max-Age=0 alone leaves the apex cookie live). Clearing only
|
|
2905
|
+
// `pms_session` host-only — the prior behaviour — left a real
|
|
2906
|
+
// `gds_session` cookie behind on this offline fallback path.
|
|
2907
|
+
for (const name of ['gds_session', 'pms_session']) {
|
|
2908
|
+
document.cookie = `${name}=; Max-Age=0; path=/`;
|
|
2909
|
+
document.cookie = `${name}=; Max-Age=0; path=/; Domain=.${location.hostname.split('.').slice(-2).join('.')}`;
|
|
2910
|
+
}
|
|
2911
|
+
window.location.assign('/');
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
// ---- Live updates (#569 / ADR 0030 → R55 / ADR 0069) -------------------
|
|
2916
|
+
// The hall used to fetch every panel once at load and then freeze until a
|
|
2917
|
+
// manual refresh — so an onboarding step that ticked server-side (e.g. after
|
|
2918
|
+
// /builder-ship) never showed. We now open a Server-Sent Events stream
|
|
2919
|
+
// (GET /api/bongos/live) that emits a tiny `nudge` whenever GDS state changes via
|
|
2920
|
+
// ANY write path — API routes, background routines, even direct psql writes
|
|
2921
|
+
// (the trigger lives in the DB; see migration 066 + src/bongos/routes/live.js).
|
|
2922
|
+
// On a nudge we re-run loadAll(), debounced so a burst coalesces into one
|
|
2923
|
+
// refresh. SSE replaced the Colyseus 'builders_hall' room (R55) so the hall's
|
|
2924
|
+
// live refresh works on a game-less Cloud Bongos boot with no Colyseus in the
|
|
2925
|
+
// process; EventSource also auto-reconnects, so there's no manual backoff here.
|
|
2926
|
+
let liveSource = null;
|
|
2927
|
+
let liveHadOpen = false;
|
|
2928
|
+
let liveRefreshTimer = null;
|
|
2929
|
+
let liveReloading = false;
|
|
2930
|
+
let visibilityWired = false;
|
|
2931
|
+
|
|
2932
|
+
// Don't yank the DOM out from under an active interaction or clobber unsent
|
|
2933
|
+
// input: if the user is typing in / focused on a field (e.g. the disciplines
|
|
2934
|
+
// editor), defer the refresh and re-arm shortly.
|
|
2935
|
+
function userIsInteracting() {
|
|
2936
|
+
const el = document.activeElement;
|
|
2937
|
+
return !!el && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName);
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
function scheduleLiveRefresh(delay = 700) {
|
|
2941
|
+
if (liveRefreshTimer) return; // already coalescing a burst
|
|
2942
|
+
liveRefreshTimer = setTimeout(async () => {
|
|
2943
|
+
liveRefreshTimer = null;
|
|
2944
|
+
if (liveReloading || document.hidden || userIsInteracting()) {
|
|
2945
|
+
scheduleLiveRefresh(1500); // not now — try again soon, don't drop it
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
liveReloading = true;
|
|
2949
|
+
try {
|
|
2950
|
+
await loadAll();
|
|
2951
|
+
} catch (err) {
|
|
2952
|
+
console.error('[builders] live refresh failed', err);
|
|
2953
|
+
} finally {
|
|
2954
|
+
liveReloading = false;
|
|
2955
|
+
}
|
|
2956
|
+
}, delay);
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
function connectLiveSource() {
|
|
2960
|
+
// EventSource missing (ancient browser) → the hall stays static; the
|
|
2961
|
+
// visibilitychange backstop below still refreshes on focus.
|
|
2962
|
+
if (liveSource || typeof EventSource === 'undefined') return;
|
|
2963
|
+
try {
|
|
2964
|
+
// Same-origin GET — the gds_session cookie rides automatically, so the
|
|
2965
|
+
// server's requireBuilder gate authenticates the viewer transparently.
|
|
2966
|
+
const es = new EventSource(`${API}/live`);
|
|
2967
|
+
liveSource = es;
|
|
2968
|
+
es.addEventListener('nudge', () => scheduleLiveRefresh());
|
|
2969
|
+
es.onopen = () => {
|
|
2970
|
+
// On a RECONNECT (not the first open) we may have missed a nudge while
|
|
2971
|
+
// the stream was down — refresh once to catch up. EventSource owns the
|
|
2972
|
+
// reconnect, so there is no manual backoff to manage here.
|
|
2973
|
+
if (liveHadOpen) scheduleLiveRefresh(500);
|
|
2974
|
+
liveHadOpen = true;
|
|
2975
|
+
};
|
|
2976
|
+
es.onerror = () => {
|
|
2977
|
+
// A transient drop leaves readyState === CONNECTING and EventSource
|
|
2978
|
+
// reconnects on its own. A terminal close (CLOSED — e.g. a 401) won't,
|
|
2979
|
+
// so drop the handle; visibilitychange can then re-open later.
|
|
2980
|
+
if (es.readyState === EventSource.CLOSED) {
|
|
2981
|
+
liveSource = null;
|
|
2982
|
+
}
|
|
2983
|
+
};
|
|
2984
|
+
} catch (err) {
|
|
2985
|
+
console.warn('[builders] live stream failed', err && err.message ? err.message : err);
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
function startLiveUpdates() {
|
|
2990
|
+
connectLiveSource();
|
|
2991
|
+
// Belt-and-braces: also refresh when the tab regains focus, covering any
|
|
2992
|
+
// nudge missed while the connection was briefly down or the laptop slept.
|
|
2993
|
+
if (!visibilityWired) {
|
|
2994
|
+
visibilityWired = true;
|
|
2995
|
+
document.addEventListener('visibilitychange', () => {
|
|
2996
|
+
if (!document.hidden) {
|
|
2997
|
+
if (!liveSource) connectLiveSource(); // resume a dropped connection
|
|
2998
|
+
scheduleLiveRefresh(200);
|
|
2999
|
+
}
|
|
3000
|
+
});
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
// The #NNN deep-link record overlay moved to task-detail.js (task 1753) —
|
|
3005
|
+
|
|
3006
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
3007
|
+
// BV1.R75: stamp the registry's <section>s into <main> BEFORE wiring events —
|
|
3008
|
+
// wireEvents() + the data-render code address the widgets by their static ids,
|
|
3009
|
+
// which only exist once the registry has mounted them.
|
|
3010
|
+
mountHallWidgets();
|
|
3011
|
+
wireEvents();
|
|
3012
|
+
// Fetch the instance's repo coords (public) so the welcome "get the code"
|
|
3013
|
+
// clone block shows this instance's own repository (ADR 0062 §3, task 1705).
|
|
3014
|
+
loadInstanceRepo();
|
|
3015
|
+
// #731: the Discord connect/link card moved to /builders/settings, and the
|
|
3016
|
+
// OAuth callback now lands there — so the ?discord=joined|linked toast is
|
|
3017
|
+
// handled by settings.js, not here.
|
|
3018
|
+
loadAll().then(() => {
|
|
3019
|
+
// Only open the live channel once we're authenticated — loadAll() returns
|
|
3020
|
+
// early (landing card) for an unauthenticated visitor, leaving selfId unset.
|
|
3021
|
+
if (window.__buildersSelfId) startLiveUpdates();
|
|
3022
|
+
}).catch((err) => {
|
|
3023
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
3024
|
+
console.error('[builders] load failed', err);
|
|
3025
|
+
toast('The page could not be loaded. Try again.');
|
|
3026
|
+
});
|
|
3027
|
+
});
|
|
3028
|
+
})();
|