@bongos/core 1.17.1 → 1.17.2
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 +244 -264
- package/.claude/skills/blocker-review/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +2 -0
- package/.claude/skills/builder-exit/SKILL.md +2 -2
- package/.claude/skills/builder-redteam/SKILL.md +3 -3
- package/.claude/skills/builder-setup/SKILL.md +2 -2
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/.claude/skills/dev/SKILL.md +1 -1
- package/.claude/skills/idea-triage/SKILL.md +2 -2
- package/.claude/skills/ideate/SKILL.md +1 -1
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/.claude/skills/new-project/SKILL.md +3 -0
- package/.claude/skills/paint/SKILL.md +1 -1
- package/GOVERNANCE.md +1 -1
- package/README.md +1 -1
- package/docs/adr/0001-stack-choice-single-vps.md +1 -1
- package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
- package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
- package/docs/adr/0020-security-model-public-stub.md +1 -1
- package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
- package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
- package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
- package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
- package/docs/adr/0030-builders-hall-live-updates.md +1 -1
- package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
- package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
- package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
- package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
- package/docs/adr/0044-per-box-live-game-preview.md +1 -1
- package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
- package/docs/adr/0057-container-cost-ledger.md +3 -3
- package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
- package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
- package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
- package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
- package/docs/adr/0096-require-reward-before-workable.md +9 -9
- package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
- package/docs/adr/0100-two-repo-instance-model.md +1 -1
- package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
- package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
- package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
- package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
- package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
- package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
- package/docs/adr/README.md +9 -6
- package/docs/architecture.md +2 -2
- package/docs/branding-contract.md +5 -4
- package/docs/canonical-permissions.md +1 -1
- package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
- package/docs/file-map.md +18 -17
- package/docs/handoff-template.md +2 -2
- package/docs/modules-contract.md +6 -1
- package/docs/onboarding/diagrams/README.md +7 -7
- package/docs/onboarding/drachmae-and-karma.md +18 -18
- package/docs/onboarding/primer.md +9 -9
- package/docs/recipes/audit-replay.md +2 -2
- package/docs/recipes/builder-box-lifecycle.md +2 -2
- package/docs/recipes/claude-design-loop.md +1 -1
- package/docs/recipes/ops-gotchas.md +1 -1
- package/docs/recipes/private-npm-distribution.md +17 -0
- package/docs/recipes/staging-environment.md +1 -1
- package/docs/recipes/upgrading-the-core.md +55 -0
- package/docs/research/cursor-competitive-analysis.md +1 -1
- package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
- package/modules/discord/module.json +2 -1
- package/modules/economy/achievements.js +9 -1
- package/modules/economy/credits.js +10 -1
- package/modules/hall-ui/public/builders.js +71 -4
- package/modules/hall-ui/public/goal-inbox.js +191 -0
- package/modules/hall-ui/public/hall-widgets.js +14 -1
- package/modules/hall-ui/public/index.html +3 -0
- package/modules/hall-ui/public/new-project-wizard.js +1 -0
- package/modules/hall-ui/public/style.css +43 -0
- package/modules/provisioning/CLAUDE.md +15 -0
- package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
- package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
- package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
- package/modules/provisioning/module.json +17 -0
- package/modules/provisioning/onboard-plan.js +37 -0
- package/modules/provisioning/provisioning.js +539 -0
- package/modules/provisioning/rate-limit.js +35 -0
- package/modules/provisioning/routes/provisioning.js +607 -0
- package/modules/provisioning/tests/provisioning.mjs +232 -0
- package/modules/provisioning/ui/.gitkeep +0 -0
- package/modules/security/secret-scrub.js +10 -0
- package/package-lock.json +2 -2
- package/package.json +7 -2
- package/scripts/discord/README.md +1 -1
- package/scripts/gds/claim.js +98 -48
- package/scripts/gds/claude-materialize.js +30 -9
- package/scripts/gds/cutover-codemod.js +241 -0
- package/scripts/gds/fitness.js +54 -0
- package/scripts/gds/init.js +59 -3
- package/scripts/gds/lint-infra-exec.js +70 -0
- package/scripts/gds/lint-workflow-exec.js +63 -0
- package/scripts/gds/mirror-lineage-check.js +14 -5
- package/scripts/gds/mirror-redact.js +8 -0
- package/scripts/gds/onboard.js +4 -0
- package/scripts/gds/package-core.js +18 -3
- package/scripts/gds/publish-manifest.js +60 -2
- package/scripts/gds/run-routine.js +51 -13
- package/scripts/gds/run-unit-tests.js +4 -0
- package/scripts/gds/update-channel.js +186 -0
- package/scripts/gds/upgrade.js +116 -9
- package/src/bongos/auth.js +14 -1
- package/src/bongos/serve-internal.js +10 -1
- package/src/branding.js +1 -0
- package/src/module-api.js +12 -1
- package/src/module-loader/loader.js +36 -4
- package/src/module-loader/manifest-schema.js +16 -1
- package/tests/branding.mjs +11 -0
- package/tests/claim_error_surface.mjs +107 -0
- package/tests/claude_materialize.mjs +20 -0
- package/tests/consumer_layout_boot.mjs +302 -0
- package/tests/cutover_codemod.mjs +184 -0
- package/tests/device_flow_start.mjs +25 -2
- package/tests/hook_core_resolve.mjs +125 -0
- package/tests/infra_exec_paths.mjs +104 -0
- package/tests/init.mjs +47 -0
- package/tests/mirror_lineage_proof.mjs +14 -0
- package/tests/module_loader.mjs +42 -0
- package/tests/module_manifest.mjs +19 -0
- package/tests/onboard.mjs +13 -0
- package/tests/package_core.mjs +8 -0
- package/tests/provisioning_github_app_routes.mjs +15 -0
- package/tests/publish_manifest.mjs +52 -1
- package/tests/run_routine.mjs +26 -1
- package/tests/run_routine_two_root.mjs +77 -0
- package/tests/scrubber_corpus.mjs +17 -0
- package/tests/update_channel.mjs +186 -0
- package/tests/upgrade.mjs +131 -0
- package/tests/workflow_exec_paths.mjs +98 -0
- package/docs/design/container-exterior-tiles.md +0 -49
- package/docs/design/container-exterior.md +0 -81
- package/docs/design/container-interior.md +0 -77
- package/docs/design/world-map-scroll.md +0 -41
- package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
- package/docs/recipes/cloudbongos-staging-gate.md +0 -139
- package/docs/recipes/cloudbongos-standup.md +0 -111
- package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
- package/docs/recipes/parallel-load-test.md +0 -32
- package/docs/recipes/pixel-art-pipeline.md +0 -411
- package/docs/recipes/staging-data.md +0 -50
- package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
- package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
- package/migrations/instance/110_medusa_planning_seed.sql +0 -70
- package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
- package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
- package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
- package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
- package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
- package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
- package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
- package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
- package/scripts/gds/art-stage.js +0 -152
- package/server.js +0 -212
- package/tests/art_key.mjs +0 -121
- package/tests/day_cycle.mjs +0 -197
- package/tests/snapshot_picker.mjs +0 -233
- package/tests/sprite_facing.mjs +0 -35
- package/tests/terrain_picker.mjs +0 -298
- package/tests/turn_in_place.mjs +0 -280
- package/tests/walkability.mjs +0 -188
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
*Read this before you claim your first work. ~10 minutes.*
|
|
4
4
|
|
|
5
|
-
You're about to build inside
|
|
5
|
+
You're about to build inside Example, a project that runs on a small purpose-built dev system. This primer is the one document that gives you enough context to ask Claude smart questions and ship your first task without getting lost. Read it linearly — it's short on purpose.
|
|
6
6
|
|
|
7
7
|
When you're done, open [/builders](https://example.com/builders) and run `/builder-start`. There's no separate "I have read it" step anymore — reading the primer is on your honor, and the work queue is already open to you.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
## What
|
|
11
|
+
## What this project is
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
This primer teaches the **platform** — how you claim work and ship it, the same on every Cloud Bongos instance. *What* you're building here — the product, its world, and its visual style — is specific to this instance and lives in **[`docs/project-context.md`](../project-context.md)**. Read that first for the product; everything below is the build system you'll use to make it.
|
|
14
14
|
|
|
15
15
|
## What the GDS is
|
|
16
16
|
|
|
@@ -66,13 +66,13 @@ Four ranks, in ancient-Greek-civilization order:
|
|
|
66
66
|
|
|
67
67
|
So the sandbox is short: ship three small `newcomer_friendly` tasks and the city opens the whole queue to you on its own (the jump to **Metic** — with its triage/trust powers — is still a deliberate Archon promotion). Rank lives on the `builders.rank` column in the production database and is checked server-side on every privileged request — **with no caching**. A demotion takes effect on your next request. You cannot escalate your own rank by editing local files; the server is the source of truth. See [ADR 0016](../adr/<redacted>.md) and [ADR 0034](../adr/<redacted>.md) for the full trust model.
|
|
68
68
|
|
|
69
|
-
##
|
|
69
|
+
## Example and karma
|
|
70
70
|
|
|
71
|
-
Two parallel reputation tracks. You don't need to *do* anything special to earn either — claim a task, do good work, ship it, and the system tracks both. The full mechanics (formulas, the kind table, the budget cap, every karma source) live in **[
|
|
71
|
+
Two parallel reputation tracks. You don't need to *do* anything special to earn either — claim a task, do good work, ship it, and the system tracks both. The full mechanics (formulas, the kind table, the budget cap, every karma source) live in **[Example & karma — how you get paid](example-and-karma.md)**; here's the shape of it.
|
|
72
72
|
|
|
73
|
-
- **
|
|
73
|
+
- **Example** (called "credits" in code) are your **equity** in the project — a score and a stake, *not* a currency you spend on anything. They only go up, and they persist if you leave and come back. Most of your example now come from a **cost-plus reward on each shipped session**: the server prices the LLM tokens your work actually burned and pays you that real cost **plus 20%** ([ADR 0054](../adr/<redacted>.md)). On top of that, each task pays its `credits_reward` adjusted by the *kind* of work (a captured learning or a resolved blocker is worth 1.5×; cleanup 0.8×), and capturing an idea that someone else then ships earns you a 25% bonus ([ADR 0023](../adr/<redacted>.md)). Credits land at the `completed → confirmed` edge — when verification passes, not at deploy. See the standings on [/builders](https://example.com/builders).
|
|
74
74
|
|
|
75
|
-
- **Karma** is a *separate* signal that measures **peer trust** — how other builders rate the *way* you work, not how much you ship. It's earned when Metic-and-above builders upvote your confirmed work (tallied weekly), when a proposal of yours is ratified, and when you capture ideas; it can also go *down* if you break things. Karma shows up on your profile (with a 30-day trend) and as a sortable column on the leaderboard, and it feeds your "builder strength" score. **It does not gate rank promotions** — those happen automatically by ship count (Xenos → Thetes after three ships) or by an Archon's deliberate decision. A builder who ships fast but breaks things gains
|
|
75
|
+
- **Karma** is a *separate* signal that measures **peer trust** — how other builders rate the *way* you work, not how much you ship. It's earned when Metic-and-above builders upvote your confirmed work (tallied weekly), when a proposal of yours is ratified, and when you capture ideas; it can also go *down* if you break things. Karma shows up on your profile (with a 30-day trend) and as a sortable column on the leaderboard, and it feeds your "builder strength" score. **It does not gate rank promotions** — those happen automatically by ship count (Xenos → Thetes after three ships) or by an Archon's deliberate decision. A builder who ships fast but breaks things gains example and loses karma; a builder who ships thoughtfully gains both.
|
|
76
76
|
|
|
77
77
|
One more thing worth knowing: builders are **uncapped by default** — your spend is tracked (it's the cost side of the same coin the reward is computed from) but never blocks you. There's no default budget. An Archon *can* set a per-builder **monthly budget cap** as a manual brake on a runaway; if one is set for you, you get a warning at 80% and `/builder-ship` pauses at 100% until it's raised ([ADR 0085](../adr/<redacted>.md)).
|
|
78
78
|
|
|
@@ -111,7 +111,7 @@ The whole project's source-of-truth index is [`CLAUDE.md`](../../CLAUDE.md). Two
|
|
|
111
111
|
|
|
112
112
|
Other depth, in roughly the order you'll need it:
|
|
113
113
|
|
|
114
|
-
- [`docs/onboarding/
|
|
114
|
+
- [`docs/onboarding/example-and-karma.md`](example-and-karma.md) — the full reward economy: how example and karma are earned, the cost-plus session reward, and the monthly budget cap
|
|
115
115
|
- [`docs/architecture.md`](../architecture.md) — live system: droplet, stack, schema, API surface
|
|
116
116
|
- [`docs/modules-contract.md`](../modules-contract.md) — the product is built as a small core plus swappable `modules/<key>/` (dev boxes, the game, Discord, art, and more); this is the live roster and how a task lands inside one
|
|
117
117
|
- [`docs/canonical-permissions.md`](../canonical-permissions.md) — who can do what (rank gates)
|
|
@@ -190,7 +190,7 @@ If a `/builder-*` command ever says "no GDS session," just run `/builder-setup`
|
|
|
190
190
|
|
|
191
191
|
Run `/builder-start` to see what you can claim — no acknowledgment step needed, the queue is open. New builders see only `newcomer_friendly` tasks — small, well-scoped, easy to finish in one sitting. Pick one that looks interesting, claim it with `/builder-claim N`, do the work, and ship it with `/builder-ship`.
|
|
192
192
|
|
|
193
|
-
When you ship your **first** task,
|
|
193
|
+
When you ship your **first** task, example land in your column and you're inducted as a citizen of Example. Ship your **third** and you graduate to **Thetes** — the one-claim limit lifts and the whole general queue opens to you. Welcome.
|
|
194
194
|
|
|
195
195
|
## Your own dev box
|
|
196
196
|
|
|
@@ -15,7 +15,7 @@ Run by an Archon when investigating an incident, a suspected compromise, or a "w
|
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| `CLAIM` | `claims.claimed_at` | Builder claimed a task — task locked to this session. |
|
|
17
17
|
| `CLAIM_END` | `claims.released_at` + `outcome` | Claim resolved — `outcome` says whether the work shipped, was abandoned, partially completed, or expired. |
|
|
18
|
-
| `CREDIT` | `credit_log.recorded_at` |
|
|
18
|
+
| `CREDIT` | `credit_log.recorded_at` | Example moved (`task.confirmed`, `ship.net_negative`, rank-change audit, etc.) — `delta` is the integer amount. |
|
|
19
19
|
| `COST` | `cost_log.recorded_at` | The builder logged a USD spend — `category` (api / compute / infra / domain / art / other) + `source`. |
|
|
20
20
|
| `SESSION_START` / `SESSION_END` | `session_logs.started_at` / `ended_at` | Claim-scoped working session boundaries. Pair with `outcome` for "shipped / partial / abandoned." |
|
|
21
21
|
| `HTTP_<METHOD>` | `audit_log.recorded_at` | Every authenticated POST / PATCH / PUT / DELETE the builder made — route, status, IP. The forensic fine-grain. |
|
|
@@ -23,7 +23,7 @@ Run by an Archon when investigating an incident, a suspected compromise, or a "w
|
|
|
23
23
|
Notably absent — and worth knowing about so you can ask follow-up questions if needed:
|
|
24
24
|
|
|
25
25
|
- **GET requests** are not in `audit_log` (by design; high volume, low signal). Read patterns aren't part of this replay.
|
|
26
|
-
- **Builder rank changes** show up as `HTTP_PATCH route=/api/gds/builders/:id/rank` rows and as `CREDIT` rows when the rank change carries a
|
|
26
|
+
- **Builder rank changes** show up as `HTTP_PATCH route=/api/gds/builders/:id/rank` rows and as `CREDIT` rows when the rank change carries a example adjustment. The new rank value lives in `audit_log.request_body_redacted`.
|
|
27
27
|
- **Game-server WebSocket events** (movement, world state) live in `world_events` and are NOT included here — this script is GDS-scoped. If a game-side investigation is needed, query `world_events` separately.
|
|
28
28
|
|
|
29
29
|
## Running the script
|
|
@@ -105,7 +105,7 @@ Put these in `/etc/example/box.env` (chmod 600 — **secrets never go in the rep
|
|
|
105
105
|
|
|
106
106
|
## Cost (Criterion C1 / cost)
|
|
107
107
|
|
|
108
|
-
A box accrues compute cost **only while `active`**. At park/deprovision the CLI computes `active hours × the size's hourly rate` and writes it to both the `box_events` ledger and `cost_log` (category `compute`, builder-attributed) — so the public status dashboard's per-builder split sees real box spend. `box.js status` prints the active monthly run-rate + total spent. The **rebill to
|
|
108
|
+
A box accrues compute cost **only while `active`**. At park/deprovision the CLI computes `active hours × the size's hourly rate` and writes it to both the `box_events` ledger and `cost_log` (category `compute`, builder-attributed) — so the public status dashboard's per-builder split sees real box spend. `box.js status` prints the active monthly run-rate + total spent. The **rebill to example/earnings** (org-funded starter → self-fund) is a separate task, [#602](https://example.com/builders#/task/602).
|
|
109
109
|
|
|
110
110
|
## Going live (one-time operator steps)
|
|
111
111
|
|
|
@@ -143,7 +143,7 @@ These spend money and are intentionally manual.
|
|
|
143
143
|
- **[#596](https://example.com/builders#/task/596)** (done) — the devcontainer (box-as-code) this provisions.
|
|
144
144
|
- **[#599](https://example.com/builders#/task/599)** — Claude Desktop managed-settings + the stable-hostname cutover the lifecycle records. (The browser-on-ramp is now the ttyd web terminal over a Cloudflare Tunnel — [#760](https://example.com/builders#/task/760)/[ADR 0038](../adr/<redacted>.md) — not the retired `claude rc` Remote-Control service.)
|
|
145
145
|
- **[#600](https://example.com/builders#/task/600)** — rank-gated **source access**: the scoped GitHub credential the box holds, granted/revoked by rank. [#598](https://example.com/builders#/task/598) records `scope`; [#600](https://example.com/builders#/task/600) enforces it.
|
|
146
|
-
- **[#602](https://example.com/builders#/task/602)** — cost passthrough (org-funded starter → self-fund via the `costs`/
|
|
146
|
+
- **[#602](https://example.com/builders#/task/602)** — cost passthrough (org-funded starter → self-fund via the `costs`/example system). [#598](https://example.com/builders#/task/598) logs the cost; [#602](https://example.com/builders#/task/602) rebills it.
|
|
147
147
|
|
|
148
148
|
## Safety properties
|
|
149
149
|
|
|
@@ -61,7 +61,7 @@ node scripts/gds/design-sync.js --surface hall-ui
|
|
|
61
61
|
Use the DesignSync MCP tool in your Claude Code session:
|
|
62
62
|
|
|
63
63
|
```
|
|
64
|
-
list_projects → pick the OTB project (or create "
|
|
64
|
+
list_projects → pick the OTB project (or create "Example — Design System")
|
|
65
65
|
finalize_plan → stage all files from manifest.files, rooted at bundlePath
|
|
66
66
|
write_files → push the bundle
|
|
67
67
|
```
|
|
@@ -119,7 +119,7 @@ Organized by topic, not chronology. New entries go in the matching section; crea
|
|
|
119
119
|
|
|
120
120
|
- **Symptom:** Visitor clicks "Sign in with GitHub" on /builders, GitHub renders its red "Be careful!" warning page or returns `?error=redirect_uri_mismatch`, and the visitor never lands back on the hall.
|
|
121
121
|
- **Root cause:** The GitHub OAuth app's **Authorization callback URL** field must match exactly what the server tells GitHub. Our server sends `${publicOrigin}/api/gds/auth/web/callback` — easy to misconfigure as just `https://example.com/` or `/auth/callback` when registering a fresh OAuth app.
|
|
122
|
-
- **Fix:** In github.com → Settings → Developer settings → OAuth Apps → (the
|
|
122
|
+
- **Fix:** In github.com → Settings → Developer settings → OAuth Apps → (the Example app) → **Authorization callback URL**, set:
|
|
123
123
|
```
|
|
124
124
|
https://example.com/api/gds/auth/web/callback
|
|
125
125
|
```
|
|
@@ -47,6 +47,23 @@ npm pack ./clients/bongos-client --dry-run # token-free content preview
|
|
|
47
47
|
npm publish ./clients/bongos-client # real (publishConfig makes it restricted; needs npm login)
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
## Cutting a core release (the policy — [ADR 0137](../adr/<redacted>.md))
|
|
51
|
+
|
|
52
|
+
Publishing is a **manual, owner-gated step** — merges to `main` do **not** auto-publish (no npm write-token lives in CI). The update subscription ([ADR 0136](../adr/<redacted>.md)) consumes whatever the owner has published, so a release is exactly: bump → publish → the subscription picks it up on its next sweep.
|
|
53
|
+
|
|
54
|
+
1. **Bump `CORE_VERSION`** in [`src/module-api.js`](../../src/module-api.js) (semver; a **patch** for a fix, **minor** for additive surface — a **major** is a deliberate, human-only event) and add a one-line changelog entry in the same file. This bump is a normal claimed task, not a release-tool side effect — the version's provenance stays in the git history + changelog.
|
|
55
|
+
2. **Publish** (owner, from a clean checkout of the released commit):
|
|
56
|
+
```sh
|
|
57
|
+
node scripts/gds/package-core.js --publish # DRY-RUN: runs the fail-closed no-leak gate, previews @bongos/core@<version>
|
|
58
|
+
npm login # once per machine (+ 2FA), or use a Classic Automation token
|
|
59
|
+
node scripts/gds/package-core.js --publish --live # permanent; the no-leak gate must pass or nothing is built/uploaded
|
|
60
|
+
```
|
|
61
|
+
The no-leak / mirror-redact gate is **built into the build** — a leaking artifact is never produced, so `--publish` cannot upload one.
|
|
62
|
+
3. **Tag the release** (optional but recommended — the deliberate, auditable marker): `git tag core-v<version> && git push origin core-v<version>`. The tag documents intent; it triggers no CI.
|
|
63
|
+
4. **Done.** Subscribed instances on the `patch`/`minor` channel take it automatically on their next routine sweep; `pinned` instances wait for a manual `bongos upgrade`. A stale (un-bumped) version is rejected by npm as a duplicate — a natural guard.
|
|
64
|
+
|
|
65
|
+
> **Automating this** (a tag-triggered CI publish) is deferred to the managed-fleet epic ([#1948](https://example.com/builders#/task/1948)); revisit when the manual step becomes a real bottleneck. See [ADR 0137](../adr/<redacted>.md) "When to revisit".
|
|
66
|
+
|
|
50
67
|
## Install a private package (any consumer machine or instance)
|
|
51
68
|
|
|
52
69
|
Add a one-line `.npmrc` (repo-root or `~/.npmrc`) that reads the token from the environment:
|
|
@@ -65,7 +65,7 @@ If `*.example.com` is **not** in the output:
|
|
|
65
65
|
The prod OAuth app points its callback at `example.com/api/gds/auth/web/callback`. Staging needs its own:
|
|
66
66
|
|
|
67
67
|
1. github.com → Settings → Developer settings → OAuth Apps → New OAuth App.
|
|
68
|
-
2. Application name: `
|
|
68
|
+
2. Application name: `Example — staging`.
|
|
69
69
|
3. Homepage URL: `https://staging.example.com`.
|
|
70
70
|
4. Authorization callback URL: `https://staging.example.com/api/gds/auth/web/callback`.
|
|
71
71
|
5. Note the **Client ID** and **Client Secret** — they go into `/home/lars/example-staging/.env` (see step 4 of the provisioning script).
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
5. **materialize `.claude/`** — refresh skills/settings/hooks from the new core (`materializeClaude`), rewriting invocations to the `bongos` bin.
|
|
14
14
|
6. **restart** — `--service <unit>` or `--restart-cmd "<cmd>"`.
|
|
15
15
|
7. **verify** — installed version == target (+ optional `--health-url`).
|
|
16
|
+
8. **auto-rollback on failure** (task [#2149](https://example.com/builders#/task/2149), default ON) — if `npm install`/`migrate` fails or the step-7 health check fails, revert the pin → reinstall the previous core → restart → re-verify → write a `rolled_back:` ledger row. Opt out with `--no-rollback-on-failure`. See the section below.
|
|
16
17
|
|
|
17
18
|
Run bare (`bongos upgrade`, no `--to`) it stays the module-compat **pre-check** only.
|
|
18
19
|
|
|
@@ -50,6 +51,60 @@ The instance runs on the droplet (co-tenant): systemd `emersonian-circles.servic
|
|
|
50
51
|
6. **Update the repo of record** — run the channel on the laptop clone (`--skip-migrate`, no DB there), commit the pin bump (drop the superseded tarball), `git push origin`.
|
|
51
52
|
7. **Align the droplet checkout** — the droplet has no deploy key (`git pull` fails; idea 509), so `git bundle` the new commit on the laptop, `scp`, `git fetch <bundle> && git reset --hard FETCH_HEAD`. `node_modules` is already the new version, so no re-`npm ci`; a final restart makes `/version` report the new commit.
|
|
52
53
|
|
|
54
|
+
## Auto-rollback on failure (task 2149)
|
|
55
|
+
|
|
56
|
+
By default (`--rollback-on-failure`, ON unless `--no-rollback-on-failure`) the channel self-heals a failed bump. If **`npm install` fails**, **`npm run migrate` fails**, or the **post-restart health check fails**, `bongos upgrade` automatically:
|
|
57
|
+
|
|
58
|
+
1. reverts the `@bongos/core` pin to the exact pre-upgrade dep string (snapshotted the instant the pin moved),
|
|
59
|
+
2. `npm install`s the previous core back,
|
|
60
|
+
3. restarts the service,
|
|
61
|
+
4. re-verifies (installed == previous, health recovers),
|
|
62
|
+
5. records a `rolled_back: <reason> (attempted N → N+1, restored N)` row in `core_upgrades`.
|
|
63
|
+
|
|
64
|
+
**The DB is NOT rolled back — only the code pin.** Core migrations are additive + forward-safe (the core migration rule), so an already-applied migration is left in place; a *failed* migration is never recorded in `schema_migrations`, so a retry re-runs it cleanly. The reversal is recorded via the always-present `note` column (not a new `outcome` column) so it lands even in the failed-migrate path — where a newer column wouldn't yet exist on that instance.
|
|
65
|
+
|
|
66
|
+
**Proven live on demo.cloudbongos.com** (task [#2157](https://example.com/builders#/task/2157)): demo, on rollback-capable 1.17.3, was upgraded to a canary carrying a deliberately-failing migration. Migrate raised → the channel auto-reverted the pin to 1.17.3, reinstalled + restarted it, `/healthz` stayed 200 (**zero downtime** — the failure was at migrate, before the restart), and a `rolled_back` ledger row was written. This is the safety gate the unattended patch-only auto-upgrade subscription (task [#2150](https://example.com/builders#/task/2150)) depends on.
|
|
67
|
+
|
|
68
|
+
## Subscribing to updates — unattended patch-only auto-upgrade (task 2150 / ADR 0136)
|
|
69
|
+
|
|
70
|
+
The sections above are the *manual* `bongos upgrade`. The **subscription** runs it for you, on a cadence, safely — the "subscribe to regular updates" experience. Policy: **patch-only by default, health-gated with auto-rollback, opt-in, autonomy-gated** (full rationale in [ADR 0136](../adr/<redacted>.md)).
|
|
71
|
+
|
|
72
|
+
**Per-instance channel** bounds how far an unattended upgrade may jump:
|
|
73
|
+
|
|
74
|
+
| channel | takes | example (from `1.17.1`) |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `pinned` | nothing (manual only) | — |
|
|
77
|
+
| `patch` *(default)* | higher patch, same `x.y` | `→ 1.17.4`, never `1.18.0` |
|
|
78
|
+
| `minor` | higher minor, same major | `→ 1.18.0`, never `2.0.0` |
|
|
79
|
+
|
|
80
|
+
A **major** is never automatic; prereleases are never auto-targeted.
|
|
81
|
+
|
|
82
|
+
**Enrolling an instance** — add it to [`config/update-subscriptions.json`](../../config/update-subscriptions.json) (ships empty). `env` carries per-instance vars merged into the upgrade child — set `PGDATABASE` so `migrate` hits the right DB (see the Gotchas):
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"defaultChannel": "patch",
|
|
87
|
+
"instances": [
|
|
88
|
+
{ "slug": "demo", "dir": "/srv/cloudbongos/demo", "service": "demo.service",
|
|
89
|
+
"healthUrl": "https://demo.cloudbongos.com/healthz", "channel": "patch",
|
|
90
|
+
"env": { "PGDATABASE": "demo" } }
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Preview vs run** — dry-run is the default; the scheduled step passes `--apply`:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
node .claude/scheduled-tasks/core-update-subscription/subscribe.js # plan only
|
|
99
|
+
node .claude/scheduled-tasks/core-update-subscription/subscribe.js --apply # upgrade
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Each subscribed instance: read its installed core → ask the registry (`npm view`, needs `NPM_TOKEN`) → resolve the newest version its channel permits → if newer, `bongos upgrade --to <v> --registry` with **auto-rollback ON**. It prints one line per instance plus a tally; a non-zero exit means an upgrade failed (already auto-reverted).
|
|
103
|
+
|
|
104
|
+
**Triple-gated, so nothing moves by accident:** the `core-update-subscription` routine is `requiresAutonomy: true` + **default OFF** in [`config/scheduled-routines.json`](../../config/scheduled-routines.json), the roster ships empty, and every upgrade is health-gated. Arm it by enabling the routine's timer on the control plane once autonomy is on.
|
|
105
|
+
|
|
106
|
+
**Not covered:** multi-host / SSH orchestration across a real fleet (the deferred fleet-control-plane epic, task [#1948](https://example.com/builders#/task/1948)); and *publishing* new patches, which stays a gated owner step (ADR 0134) — a follow-up task decides whether merges auto-publish.
|
|
107
|
+
|
|
53
108
|
## Gotchas
|
|
54
109
|
|
|
55
110
|
- **`PGDATABASE` is load-bearing on a co-tenant droplet.** `migrate.sh` defaults `PGDATABASE` to `example` — **production**. Always set `PGDATABASE=<instance-db>` explicitly, or you migrate the wrong database.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Cursor (Anysphere) — Competitive-Threat Analysis for Cloud Bongos
|
|
2
2
|
|
|
3
|
-
**A competitor-research document for Cloud Bongos /
|
|
3
|
+
**A competitor-research document for Cloud Bongos / Example**
|
|
4
4
|
Prepared 2026-06-22 · the **first committed competitor doc** in this repo · companion to the (uncommitted) `component-based-dev-research.md` deep-research doc · task [#1403](https://example.com/builders#/task/1403)
|
|
5
5
|
|
|
6
6
|
---
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# LLM Model-Agnostic Platform + Cheaper Grader — Tools Audit & Architecture Path
|
|
2
2
|
|
|
3
|
-
**A research/architecture spike for Cloud Bongos /
|
|
3
|
+
**A research/architecture spike for Cloud Bongos / Example**
|
|
4
4
|
Prepared 2026-06-28 · task [#1069](https://example.com/builders#/task/1069) · GDS-V4 · track `internal`
|
|
5
5
|
|
|
6
6
|
---
|
|
@@ -424,7 +424,15 @@ async function getLeaderboardWithAchievements(limit = 10, perBuilderCap = 5) {
|
|
|
424
424
|
ORDER BY a.sort_order
|
|
425
425
|
LIMIT $2
|
|
426
426
|
) t
|
|
427
|
-
), ARRAY[]::text[]) AS achievements
|
|
427
|
+
), ARRAY[]::text[]) AS achievements,
|
|
428
|
+
-- task 1473: mirror of credits.leaderboard's tokens_used — total
|
|
429
|
+
-- transcript tokens across this builder's sessions, so the public
|
|
430
|
+
-- leaderboard shows efficiency (tokens per drachma) beside credits.
|
|
431
|
+
COALESCE((
|
|
432
|
+
SELECT SUM(sr.total_tokens)
|
|
433
|
+
FROM session_records sr
|
|
434
|
+
WHERE sr.builder_id = b.id
|
|
435
|
+
), 0)::bigint AS tokens_used
|
|
428
436
|
FROM builders b
|
|
429
437
|
ORDER BY b.total_credits DESC, b.created_at ASC
|
|
430
438
|
LIMIT $1`,
|
|
@@ -37,7 +37,16 @@ async function leaderboard(limit = 20) {
|
|
|
37
37
|
ORDER BY a.sort_order
|
|
38
38
|
LIMIT 5
|
|
39
39
|
) t
|
|
40
|
-
), ARRAY[]::text[]) AS achievements
|
|
40
|
+
), ARRAY[]::text[]) AS achievements,
|
|
41
|
+
-- task 1473: total transcript tokens across all of this builder's
|
|
42
|
+
-- recorded sessions, so the hall leaderboard can show efficiency
|
|
43
|
+
-- (tokens spent per drachma earned) next to credits. Mirrored in
|
|
44
|
+
-- achievements.getLeaderboardWithAchievements (the /public query).
|
|
45
|
+
COALESCE((
|
|
46
|
+
SELECT SUM(sr.total_tokens)
|
|
47
|
+
FROM session_records sr
|
|
48
|
+
WHERE sr.builder_id = builders.id
|
|
49
|
+
), 0)::bigint AS tokens_used
|
|
41
50
|
FROM builders
|
|
42
51
|
ORDER BY total_credits DESC, created_at ASC
|
|
43
52
|
LIMIT $1`,
|
|
@@ -1782,6 +1782,45 @@
|
|
|
1782
1782
|
}).join('');
|
|
1783
1783
|
}
|
|
1784
1784
|
|
|
1785
|
+
// ---- idea-inbox triage cue (task 1471) -----------------------------------
|
|
1786
|
+
|
|
1787
|
+
// Render the Metic+ triage widget: the open-idea count + a few recent titles,
|
|
1788
|
+
// each deep-linking to the #/idea/N overlay (which carries the Metic+ Discard
|
|
1789
|
+
// control). Only ever called for a Metic+ viewer (loadAll gates the fetch), so
|
|
1790
|
+
// reaching here is what un-hides the section — a non-triager never loads or
|
|
1791
|
+
// sees any idea data. Fully failure-isolated by its caller.
|
|
1792
|
+
function renderTriageInbox(data) {
|
|
1793
|
+
const section = document.getElementById('triage-inbox');
|
|
1794
|
+
if (!section) return;
|
|
1795
|
+
const open = (data && Array.isArray(data.ideas) ? data.ideas : [])
|
|
1796
|
+
.filter((i) => i && i.status === 'open');
|
|
1797
|
+
const openCount = data && data.open_count != null ? data.open_count : open.length;
|
|
1798
|
+
|
|
1799
|
+
const countEl = document.getElementById('triage-inbox-count');
|
|
1800
|
+
if (countEl) {
|
|
1801
|
+
countEl.textContent = `${fmtNum(openCount)} open`;
|
|
1802
|
+
countEl.hidden = false;
|
|
1803
|
+
}
|
|
1804
|
+
const listEl = document.getElementById('triage-inbox-list');
|
|
1805
|
+
if (listEl) {
|
|
1806
|
+
if (!open.length) {
|
|
1807
|
+
listEl.innerHTML = '<li class="triage-inbox__empty">Inbox clear — nothing awaiting triage.</li>';
|
|
1808
|
+
} else {
|
|
1809
|
+
listEl.innerHTML = open.slice(0, 8).map((i) => {
|
|
1810
|
+
const title = escapeHtml(i.title || `idea #${i.id}`);
|
|
1811
|
+
const kind = i.kind
|
|
1812
|
+
? `<span class="triage-inbox__kind">${escapeHtml(i.kind)}</span>`
|
|
1813
|
+
: '';
|
|
1814
|
+
return `<li class="triage-inbox__item">`
|
|
1815
|
+
+ `<a class="triage-inbox__link" href="#/idea/${encodeURIComponent(i.id)}">${title}</a>`
|
|
1816
|
+
+ kind
|
|
1817
|
+
+ `</li>`;
|
|
1818
|
+
}).join('');
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
section.hidden = false;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1785
1824
|
// ---- leaderboard ---------------------------------------------------------
|
|
1786
1825
|
|
|
1787
1826
|
// Locked at 8 for V2 (per limitations/pms-v2.md). Hardcoding here avoids an
|
|
@@ -1816,7 +1855,7 @@
|
|
|
1816
1855
|
page: 0,
|
|
1817
1856
|
pageSize: 25, // task 1750: top-100 Roll — 25/page keeps it to a few pages
|
|
1818
1857
|
|
|
1819
|
-
sortBy: 'drachmae', // 'rank' | 'drachmae' | 'karma'
|
|
1858
|
+
sortBy: 'drachmae', // 'rank' | 'drachmae' | 'karma' | 'tokens'
|
|
1820
1859
|
sortDir: 'desc', // 'desc' | 'asc'
|
|
1821
1860
|
};
|
|
1822
1861
|
|
|
@@ -1852,6 +1891,23 @@
|
|
|
1852
1891
|
})
|
|
1853
1892
|
.map(({ row }) => row);
|
|
1854
1893
|
}
|
|
1894
|
+
if (lbState.sortBy === 'tokens') {
|
|
1895
|
+
// task 1473: sort by total tokens used. tokens_used arrives as a string
|
|
1896
|
+
// (pg bigint), so coerce. Tie-break on drachmae DESC then server order,
|
|
1897
|
+
// matching the karma branch.
|
|
1898
|
+
return indexed
|
|
1899
|
+
.sort((a, b) => {
|
|
1900
|
+
const at = Number(a.row.tokens_used) || 0;
|
|
1901
|
+
const bt = Number(b.row.tokens_used) || 0;
|
|
1902
|
+
const diff = (at - bt) * dir;
|
|
1903
|
+
if (diff !== 0) return diff;
|
|
1904
|
+
const ac = Number(a.row.total_credits) || 0;
|
|
1905
|
+
const bc = Number(b.row.total_credits) || 0;
|
|
1906
|
+
if (ac !== bc) return bc - ac;
|
|
1907
|
+
return a.idx - b.idx;
|
|
1908
|
+
})
|
|
1909
|
+
.map(({ row }) => row);
|
|
1910
|
+
}
|
|
1855
1911
|
if (lbState.sortBy === 'drachmae') {
|
|
1856
1912
|
// Server already DESCs by drachmae, so the default mode is a no-op
|
|
1857
1913
|
// copy. The ASC branch flips it.
|
|
@@ -1869,13 +1925,14 @@
|
|
|
1869
1925
|
}
|
|
1870
1926
|
|
|
1871
1927
|
// Map a sortable column id → the lbState.sortBy key. Adding a new sortable
|
|
1872
|
-
// column means adding an entry here + a header in
|
|
1873
|
-
// `lb-sort-<key>`. The handler walks aria-sort across all
|
|
1928
|
+
// column means adding an entry here + a header in hall-widgets.js with id
|
|
1929
|
+
// `lb-sort-<key>`. The handler walks aria-sort across all sortable headers so
|
|
1874
1930
|
// only one column shows its arrow at a time.
|
|
1875
1931
|
const LB_SORT_HEADERS = {
|
|
1876
1932
|
rank: 'lb-sort-rank',
|
|
1877
1933
|
drachmae: 'lb-sort-drachmae',
|
|
1878
1934
|
karma: 'lb-sort-karma',
|
|
1935
|
+
tokens: 'lb-sort-tokens',
|
|
1879
1936
|
};
|
|
1880
1937
|
|
|
1881
1938
|
// #757: this builder's place on the Roll, ranked by drachmae (total_credits)
|
|
@@ -1902,7 +1959,7 @@
|
|
|
1902
1959
|
const tbody = $('#leaderboard tbody');
|
|
1903
1960
|
|
|
1904
1961
|
if (!slice.length) {
|
|
1905
|
-
tbody.innerHTML = emptyRow(
|
|
1962
|
+
tbody.innerHTML = emptyRow(6, 'No more builders to show.');
|
|
1906
1963
|
} else {
|
|
1907
1964
|
tbody.innerHTML = slice.map((b, i) => {
|
|
1908
1965
|
const place = start + i + 1;
|
|
@@ -1922,6 +1979,7 @@
|
|
|
1922
1979
|
<td class="ledger__rank">${rank}</td>
|
|
1923
1980
|
<td class="ledger__drachmae">${fmtNum(b.total_credits)}</td>
|
|
1924
1981
|
<td class="ledger__drachmae">${fmtNum(b.karma)}</td>
|
|
1982
|
+
<td class="ledger__drachmae" title="${Number(b.total_credits) > 0 ? `${fmtNum(Math.round(Number(b.tokens_used) / Number(b.total_credits)))} tokens per drachma` : 'no drachmae earned yet'}">${fmtNum(b.tokens_used)}</td>
|
|
1925
1983
|
</tr>
|
|
1926
1984
|
`;
|
|
1927
1985
|
}).join('');
|
|
@@ -2633,6 +2691,15 @@
|
|
|
2633
2691
|
applyNewcomerGate();
|
|
2634
2692
|
renderNav(me.builder, me.onboarding || null);
|
|
2635
2693
|
|
|
2694
|
+
// task 1471: idea-inbox triage cue — Metic+ only (triage is a Metic+ action).
|
|
2695
|
+
// Fetched fire-and-forget so a slow/large inbox can't hold up the hall; the
|
|
2696
|
+
// widget is hiddenByDefault, so a non-triager never loads or sees idea data.
|
|
2697
|
+
if (['metic', 'archon'].includes(normalizeRank(me.builder?.rank))) {
|
|
2698
|
+
getJSON(`${API}/inbox`)
|
|
2699
|
+
.then((data) => renderTriageInbox(data))
|
|
2700
|
+
.catch(() => { /* nice-to-have; never break the hall */ });
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2636
2703
|
// Then the rest, in parallel. Any individual section failure shouldn't take
|
|
2637
2704
|
// down the page — log and show an inline placeholder.
|
|
2638
2705
|
// #726: the per-skill model picker (Voices) moved to /builders/settings,
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// goal-inbox.js — the home-page, two-sided goal-membership inbox (task 1954, ADR 0112).
|
|
2
|
+
//
|
|
3
|
+
// Surfaces the actionable slice of a builder's private-goals queue in ONE place:
|
|
4
|
+
// • "Invitations to you" — pending invites addressed to me (Accept / Decline).
|
|
5
|
+
// • "Requests to approve" — pending join-requests on goals I own or manage
|
|
6
|
+
// (Approve / Deny).
|
|
7
|
+
// • a quiet "your pending requests" line for my own outstanding asks.
|
|
8
|
+
// Reads GET /goals/inbox (task 1952) which already scopes each bucket to the caller.
|
|
9
|
+
// Registers into the window.OTB hall-widget registry (dom-utils.js) exactly like
|
|
10
|
+
// goals.js — same IIFE guard, same BongosClient result-mode getJSON, view:'home'.
|
|
11
|
+
//
|
|
12
|
+
// The whole card is HIDDEN when the inbox is empty, so a quiet home page pays nothing
|
|
13
|
+
// for it. On an older server without the route (404) — or a signed-out visitor (401) —
|
|
14
|
+
// it renders nothing (feature-detect), never an error card.
|
|
15
|
+
(function () {
|
|
16
|
+
'use strict';
|
|
17
|
+
if (!window.OTB || typeof window.OTB.contributeHallWidget !== 'function') return;
|
|
18
|
+
const { escapeHtml, toast, rankLabel } = window.OTB;
|
|
19
|
+
const API = '/api/bongos';
|
|
20
|
+
// The generated typed client (served as window.BongosClient), result-mode so the
|
|
21
|
+
// status-inspecting control flow below (401/404 → render nothing) reads 1:1.
|
|
22
|
+
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
23
|
+
|
|
24
|
+
async function getJSON(path) {
|
|
25
|
+
const res = await api.request('GET', path);
|
|
26
|
+
if (res.status === 401) { const e = new Error('auth-needed'); e.code = 'auth-needed'; throw e; }
|
|
27
|
+
if (res.status === 404) { const e = new Error('not-found'); e.code = 'not-found'; throw e; }
|
|
28
|
+
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
29
|
+
return res.data;
|
|
30
|
+
}
|
|
31
|
+
async function postJSON(path, body) {
|
|
32
|
+
const res = await api.request('POST', path, { body: body || {} });
|
|
33
|
+
if (!res.ok) { const e = new Error(`${path} → ${res.status}`); e.status = res.status; e.data = res.data; throw e; }
|
|
34
|
+
return res.data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let host = null;
|
|
38
|
+
let busy = false;
|
|
39
|
+
|
|
40
|
+
// The goal-detail hash route on the Goals page (matches goals.js's row link).
|
|
41
|
+
const goalHref = (goalId) => `/goals#/goal/${encodeURIComponent(goalId)}`;
|
|
42
|
+
const noteHtml = (note) => (note ? `<p class="ginbox__note">“${escapeHtml(note)}”</p>` : '');
|
|
43
|
+
|
|
44
|
+
function invitationRow(inv) {
|
|
45
|
+
const who = inv.invited_by_name || inv.invited_by_login || 'someone';
|
|
46
|
+
return `<li class="ginbox__item" data-goal="${escapeHtml(String(inv.goal_id))}">
|
|
47
|
+
<div class="ginbox__body">
|
|
48
|
+
<a class="ginbox__goal" href="${goalHref(inv.goal_id)}" title="Open ${escapeHtml(inv.goal_title || 'goal')}">${escapeHtml(inv.goal_title || 'a goal')}</a>
|
|
49
|
+
<span class="ginbox__sub">invited by ${escapeHtml(who)}</span>
|
|
50
|
+
${noteHtml(inv.note)}
|
|
51
|
+
</div>
|
|
52
|
+
<div class="ginbox__actions">
|
|
53
|
+
<button class="btn-ghost" type="button" data-accept-inv="${escapeHtml(String(inv.id))}">Accept</button>
|
|
54
|
+
<button class="btn-ghost" type="button" data-decline-inv="${escapeHtml(String(inv.id))}">Decline</button>
|
|
55
|
+
</div>
|
|
56
|
+
</li>`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requestRow(r) {
|
|
60
|
+
const who = r.display_name || r.github_login || 'a builder';
|
|
61
|
+
const rank = r.rank ? ` · ${escapeHtml(rankLabel(r.rank, { empty: '' }))}` : '';
|
|
62
|
+
return `<li class="ginbox__item" data-goal="${escapeHtml(String(r.goal_id))}">
|
|
63
|
+
<div class="ginbox__body">
|
|
64
|
+
<a class="ginbox__goal" href="${goalHref(r.goal_id)}" title="Open ${escapeHtml(r.goal_title || 'goal')}">${escapeHtml(r.goal_title || 'a goal')}</a>
|
|
65
|
+
<span class="ginbox__sub">${escapeHtml(who)}${rank} asks to join</span>
|
|
66
|
+
${noteHtml(r.note)}
|
|
67
|
+
</div>
|
|
68
|
+
<div class="ginbox__actions">
|
|
69
|
+
<button class="btn-ghost" type="button" data-approve-req="${escapeHtml(String(r.id))}">Approve</button>
|
|
70
|
+
<button class="btn-ghost" type="button" data-deny-req="${escapeHtml(String(r.id))}">Deny</button>
|
|
71
|
+
</div>
|
|
72
|
+
</li>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function pendingLine(myPending) {
|
|
76
|
+
if (!myPending.length) return '';
|
|
77
|
+
const links = myPending
|
|
78
|
+
.map((r) => `<a href="${goalHref(r.goal_id)}">${escapeHtml(r.goal_title || 'a goal')}</a>`)
|
|
79
|
+
.join(', ');
|
|
80
|
+
const noun = myPending.length === 1 ? 'request' : 'requests';
|
|
81
|
+
return `<p class="ginbox__pending card-foot">Your pending join ${noun}: ${links} — awaiting approval.</p>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function render(inbox) {
|
|
85
|
+
const invitations = inbox.invitations || [];
|
|
86
|
+
const joinRequests = inbox.joinRequests || [];
|
|
87
|
+
const myPending = inbox.myPendingRequests || [];
|
|
88
|
+
// Hide the whole card when there is nothing to see OR act on.
|
|
89
|
+
if (!invitations.length && !joinRequests.length && !myPending.length) {
|
|
90
|
+
host.hidden = true;
|
|
91
|
+
host.innerHTML = '';
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const actionable = invitations.length + joinRequests.length;
|
|
95
|
+
let html = `<h2 id="goal-inbox-h" class="scroll__h">Goal inbox${actionable ? `<span class="count-pill">${actionable}</span>` : ''}</h2>`;
|
|
96
|
+
if (actionable) {
|
|
97
|
+
html += '<p class="scroll__lede">Invitations and join-requests waiting on you.</p>';
|
|
98
|
+
}
|
|
99
|
+
if (invitations.length) {
|
|
100
|
+
html += '<h3 class="ginbox__sec-h">Invitations to you</h3>';
|
|
101
|
+
html += `<ul class="ginbox">${invitations.map(invitationRow).join('')}</ul>`;
|
|
102
|
+
}
|
|
103
|
+
if (joinRequests.length) {
|
|
104
|
+
html += '<h3 class="ginbox__sec-h">Requests to approve</h3>';
|
|
105
|
+
html += `<ul class="ginbox">${joinRequests.map(requestRow).join('')}</ul>`;
|
|
106
|
+
}
|
|
107
|
+
html += pendingLine(myPending);
|
|
108
|
+
host.innerHTML = html;
|
|
109
|
+
host.hidden = false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function load() {
|
|
113
|
+
if (!host) return;
|
|
114
|
+
try {
|
|
115
|
+
const inbox = await getJSON(`${API}/goals/inbox`);
|
|
116
|
+
render(inbox);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
// Signed out (401) or an older server without the route (404) → render nothing.
|
|
119
|
+
host.hidden = true;
|
|
120
|
+
host.innerHTML = '';
|
|
121
|
+
if (err.code !== 'auth-needed' && err.code !== 'not-found') {
|
|
122
|
+
console.error('[hall] goal inbox load failed', err);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// One action, then refresh the widget (the row it acted on disappears, and the
|
|
128
|
+
// count-pill/live counter re-derive from the fresh inbox). Disable while in flight
|
|
129
|
+
// so a double-click can't fire the resolve twice (the server 409s a re-resolve, but
|
|
130
|
+
// the UI shouldn't invite it).
|
|
131
|
+
async function act(fn, okMsg) {
|
|
132
|
+
if (busy) return;
|
|
133
|
+
busy = true;
|
|
134
|
+
host.querySelectorAll('button').forEach((b) => { b.disabled = true; });
|
|
135
|
+
try {
|
|
136
|
+
await fn();
|
|
137
|
+
toast(okMsg);
|
|
138
|
+
await load();
|
|
139
|
+
} catch (err) {
|
|
140
|
+
toast((err && err.data && err.data.message) || 'Could not do that just now — refresh and retry.');
|
|
141
|
+
host.querySelectorAll('button').forEach((b) => { b.disabled = false; });
|
|
142
|
+
} finally {
|
|
143
|
+
busy = false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function onClick(e) {
|
|
148
|
+
const item = e.target.closest('.ginbox__item');
|
|
149
|
+
const goalId = item && item.dataset.goal;
|
|
150
|
+
if (!goalId) return;
|
|
151
|
+
|
|
152
|
+
const accept = e.target.closest('[data-accept-inv]');
|
|
153
|
+
if (accept) {
|
|
154
|
+
act(() => postJSON(`${API}/goals/${encodeURIComponent(goalId)}/invitations/${encodeURIComponent(accept.dataset.acceptInv)}/respond`, { action: 'accept' }),
|
|
155
|
+
'Invitation accepted — you’re now a member.');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const decline = e.target.closest('[data-decline-inv]');
|
|
159
|
+
if (decline) {
|
|
160
|
+
act(() => postJSON(`${API}/goals/${encodeURIComponent(goalId)}/invitations/${encodeURIComponent(decline.dataset.declineInv)}/respond`, { action: 'decline' }),
|
|
161
|
+
'Invitation declined.');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const approve = e.target.closest('[data-approve-req]');
|
|
165
|
+
if (approve) {
|
|
166
|
+
act(() => postJSON(`${API}/goals/${encodeURIComponent(goalId)}/requests/${encodeURIComponent(approve.dataset.approveReq)}/respond`, { action: 'approve' }),
|
|
167
|
+
'Approved — they’re now a member.');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const deny = e.target.closest('[data-deny-req]');
|
|
171
|
+
if (deny) {
|
|
172
|
+
act(() => postJSON(`${API}/goals/${encodeURIComponent(goalId)}/requests/${encodeURIComponent(deny.dataset.denyReq)}/respond`, { action: 'deny' }),
|
|
173
|
+
'Request denied.');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function mount(hostEl) {
|
|
178
|
+
host = hostEl;
|
|
179
|
+
host.hidden = true; // stays hidden until load() finds something actionable
|
|
180
|
+
host.addEventListener('click', onClick);
|
|
181
|
+
load();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// order 24 → sits at the top of the Home main zone (just above the idea-triage
|
|
185
|
+
// inbox), since it is personal + actionable. hiddenByDefault so an empty inbox
|
|
186
|
+
// costs nothing until load() un-hides it.
|
|
187
|
+
window.OTB.contributeHallWidget({
|
|
188
|
+
id: 'goal-inbox-scroll', label: 'Goal inbox', order: 24, mount,
|
|
189
|
+
labelledby: 'goal-inbox-h', view: 'home', zone: 'home-main', hiddenByDefault: true,
|
|
190
|
+
});
|
|
191
|
+
})();
|