@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.
Files changed (164) hide show
  1. package/.bongos-core.json +244 -264
  2. package/.claude/skills/blocker-review/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +2 -0
  4. package/.claude/skills/builder-exit/SKILL.md +2 -2
  5. package/.claude/skills/builder-redteam/SKILL.md +3 -3
  6. package/.claude/skills/builder-setup/SKILL.md +2 -2
  7. package/.claude/skills/builder-start/SKILL.md +1 -1
  8. package/.claude/skills/dev/SKILL.md +1 -1
  9. package/.claude/skills/idea-triage/SKILL.md +2 -2
  10. package/.claude/skills/ideate/SKILL.md +1 -1
  11. package/.claude/skills/merge-mode/SKILL.md +1 -1
  12. package/.claude/skills/new-project/SKILL.md +3 -0
  13. package/.claude/skills/paint/SKILL.md +1 -1
  14. package/GOVERNANCE.md +1 -1
  15. package/README.md +1 -1
  16. package/docs/adr/0001-stack-choice-single-vps.md +1 -1
  17. package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
  18. package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
  19. package/docs/adr/0020-security-model-public-stub.md +1 -1
  20. package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
  21. package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
  22. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
  23. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
  24. package/docs/adr/0030-builders-hall-live-updates.md +1 -1
  25. package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
  26. package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
  27. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
  28. package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
  29. package/docs/adr/0044-per-box-live-game-preview.md +1 -1
  30. package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
  31. package/docs/adr/0057-container-cost-ledger.md +3 -3
  32. package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
  33. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
  34. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
  35. package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
  36. package/docs/adr/0096-require-reward-before-workable.md +9 -9
  37. package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
  38. package/docs/adr/0100-two-repo-instance-model.md +1 -1
  39. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
  40. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
  41. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
  42. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
  43. package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
  44. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
  45. package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
  46. package/docs/adr/README.md +9 -6
  47. package/docs/architecture.md +2 -2
  48. package/docs/branding-contract.md +5 -4
  49. package/docs/canonical-permissions.md +1 -1
  50. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  51. package/docs/file-map.md +18 -17
  52. package/docs/handoff-template.md +2 -2
  53. package/docs/modules-contract.md +6 -1
  54. package/docs/onboarding/diagrams/README.md +7 -7
  55. package/docs/onboarding/drachmae-and-karma.md +18 -18
  56. package/docs/onboarding/primer.md +9 -9
  57. package/docs/recipes/audit-replay.md +2 -2
  58. package/docs/recipes/builder-box-lifecycle.md +2 -2
  59. package/docs/recipes/claude-design-loop.md +1 -1
  60. package/docs/recipes/ops-gotchas.md +1 -1
  61. package/docs/recipes/private-npm-distribution.md +17 -0
  62. package/docs/recipes/staging-environment.md +1 -1
  63. package/docs/recipes/upgrading-the-core.md +55 -0
  64. package/docs/research/cursor-competitive-analysis.md +1 -1
  65. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
  66. package/modules/discord/module.json +2 -1
  67. package/modules/economy/achievements.js +9 -1
  68. package/modules/economy/credits.js +10 -1
  69. package/modules/hall-ui/public/builders.js +71 -4
  70. package/modules/hall-ui/public/goal-inbox.js +191 -0
  71. package/modules/hall-ui/public/hall-widgets.js +14 -1
  72. package/modules/hall-ui/public/index.html +3 -0
  73. package/modules/hall-ui/public/new-project-wizard.js +1 -0
  74. package/modules/hall-ui/public/style.css +43 -0
  75. package/modules/provisioning/CLAUDE.md +15 -0
  76. package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
  77. package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
  78. package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
  79. package/modules/provisioning/module.json +17 -0
  80. package/modules/provisioning/onboard-plan.js +37 -0
  81. package/modules/provisioning/provisioning.js +539 -0
  82. package/modules/provisioning/rate-limit.js +35 -0
  83. package/modules/provisioning/routes/provisioning.js +607 -0
  84. package/modules/provisioning/tests/provisioning.mjs +232 -0
  85. package/modules/provisioning/ui/.gitkeep +0 -0
  86. package/modules/security/secret-scrub.js +10 -0
  87. package/package-lock.json +2 -2
  88. package/package.json +7 -2
  89. package/scripts/discord/README.md +1 -1
  90. package/scripts/gds/claim.js +98 -48
  91. package/scripts/gds/claude-materialize.js +30 -9
  92. package/scripts/gds/cutover-codemod.js +241 -0
  93. package/scripts/gds/fitness.js +54 -0
  94. package/scripts/gds/init.js +59 -3
  95. package/scripts/gds/lint-infra-exec.js +70 -0
  96. package/scripts/gds/lint-workflow-exec.js +63 -0
  97. package/scripts/gds/mirror-lineage-check.js +14 -5
  98. package/scripts/gds/mirror-redact.js +8 -0
  99. package/scripts/gds/onboard.js +4 -0
  100. package/scripts/gds/package-core.js +18 -3
  101. package/scripts/gds/publish-manifest.js +60 -2
  102. package/scripts/gds/run-routine.js +51 -13
  103. package/scripts/gds/run-unit-tests.js +4 -0
  104. package/scripts/gds/update-channel.js +186 -0
  105. package/scripts/gds/upgrade.js +116 -9
  106. package/src/bongos/auth.js +14 -1
  107. package/src/bongos/serve-internal.js +10 -1
  108. package/src/branding.js +1 -0
  109. package/src/module-api.js +12 -1
  110. package/src/module-loader/loader.js +36 -4
  111. package/src/module-loader/manifest-schema.js +16 -1
  112. package/tests/branding.mjs +11 -0
  113. package/tests/claim_error_surface.mjs +107 -0
  114. package/tests/claude_materialize.mjs +20 -0
  115. package/tests/consumer_layout_boot.mjs +302 -0
  116. package/tests/cutover_codemod.mjs +184 -0
  117. package/tests/device_flow_start.mjs +25 -2
  118. package/tests/hook_core_resolve.mjs +125 -0
  119. package/tests/infra_exec_paths.mjs +104 -0
  120. package/tests/init.mjs +47 -0
  121. package/tests/mirror_lineage_proof.mjs +14 -0
  122. package/tests/module_loader.mjs +42 -0
  123. package/tests/module_manifest.mjs +19 -0
  124. package/tests/onboard.mjs +13 -0
  125. package/tests/package_core.mjs +8 -0
  126. package/tests/provisioning_github_app_routes.mjs +15 -0
  127. package/tests/publish_manifest.mjs +52 -1
  128. package/tests/run_routine.mjs +26 -1
  129. package/tests/run_routine_two_root.mjs +77 -0
  130. package/tests/scrubber_corpus.mjs +17 -0
  131. package/tests/update_channel.mjs +186 -0
  132. package/tests/upgrade.mjs +131 -0
  133. package/tests/workflow_exec_paths.mjs +98 -0
  134. package/docs/design/container-exterior-tiles.md +0 -49
  135. package/docs/design/container-exterior.md +0 -81
  136. package/docs/design/container-interior.md +0 -77
  137. package/docs/design/world-map-scroll.md +0 -41
  138. package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
  139. package/docs/recipes/cloudbongos-staging-gate.md +0 -139
  140. package/docs/recipes/cloudbongos-standup.md +0 -111
  141. package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
  142. package/docs/recipes/parallel-load-test.md +0 -32
  143. package/docs/recipes/pixel-art-pipeline.md +0 -411
  144. package/docs/recipes/staging-data.md +0 -50
  145. package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
  146. package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
  147. package/migrations/instance/110_medusa_planning_seed.sql +0 -70
  148. package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
  149. package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
  150. package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
  151. package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
  152. package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
  153. package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
  154. package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
  155. package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
  156. package/scripts/gds/art-stage.js +0 -152
  157. package/server.js +0 -212
  158. package/tests/art_key.mjs +0 -121
  159. package/tests/day_cycle.mjs +0 -197
  160. package/tests/snapshot_picker.mjs +0 -233
  161. package/tests/sprite_facing.mjs +0 -35
  162. package/tests/terrain_picker.mjs +0 -298
  163. package/tests/turn_in_place.mjs +0 -280
  164. package/tests/walkability.mjs +0 -188
@@ -0,0 +1,232 @@
1
+ // modules/provisioning/tests/provisioning.mjs
2
+ //
3
+ // DB-free unit smoke for the provisioning module's domain slice (ADR 0111). Focus:
4
+ // the P4 (#1945) Caddy on-demand-TLS ALLOW-LIST helpers — normalizeHostname (pure)
5
+ // and isProvisionedHostname (a fake { query }) — plus a sanity check of the P1 pure
6
+ // helpers. No DB, no network: `db` is a recording fake. Auto-discovered + run by
7
+ // scripts/gds/run-unit-tests.js when the `provisioning` module is enabled (it is on
8
+ // OTB). Run standalone: node modules/provisioning/tests/provisioning.mjs
9
+
10
+ import { strict as assert } from 'node:assert';
11
+ import { createRequire } from 'node:module';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const P = require('../provisioning.js');
15
+ const RL = require('../rate-limit.js');
16
+
17
+ let passed = 0, failed = 0;
18
+ function t(name, fn) {
19
+ try { fn(); passed++; console.log(` PASS ${name}`); }
20
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
21
+ }
22
+ async function ta(name, fn) {
23
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
24
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
25
+ }
26
+
27
+ console.log('normalizeHostname (on-demand-TLS allow-list, P4):');
28
+
29
+ t('lowercases, trims, drops a trailing dot + a :port', () => {
30
+ assert.equal(P.normalizeHostname(' Mercury.Example.COM. '), 'mercury.example.com');
31
+ assert.equal(P.normalizeHostname('mercury.example.com:443'), 'mercury.example.com');
32
+ });
33
+
34
+ t('rejects a bare TLD / empty / malformed names (fail-closed → the caller denies)', () => {
35
+ assert.equal(P.normalizeHostname('com'), '');
36
+ assert.equal(P.normalizeHostname(''), '');
37
+ assert.equal(P.normalizeHostname(null), '');
38
+ assert.equal(P.normalizeHostname('-bad.example.com'), '');
39
+ assert.equal(P.normalizeHostname('bad-.example.com'), '');
40
+ assert.equal(P.normalizeHostname('has space.example.com'), '');
41
+ assert.equal(P.normalizeHostname('a..b.com'), '');
42
+ });
43
+
44
+ t('accepts a normal multi-label host', () => {
45
+ assert.equal(P.normalizeHostname('a.b.example.com'), 'a.b.example.com');
46
+ });
47
+
48
+ console.log('\nisProvisionedHostname (the Caddy `ask` allow-list, P4):');
49
+
50
+ // A fake db whose query returns the status row a lookup fn decides, keyed on $1.
51
+ function fakeDb(statusFor) {
52
+ return { query: async (_sql, params) => {
53
+ const s = statusFor(String(params[0]));
54
+ return { rows: s ? [{ status: s }] : [] };
55
+ } };
56
+ }
57
+
58
+ await ta('true ONLY for a provisioning|active instance; false for every other state or unknown', async () => {
59
+ const byHost = {
60
+ 'live.example.com': 'active',
61
+ 'mid.example.com': 'provisioning',
62
+ 'gone.example.com': 'torn_down',
63
+ 'req.example.com': 'requested',
64
+ 'err.example.com': 'error',
65
+ 'down.example.com': 'tearing_down',
66
+ };
67
+ const db = fakeDb((h) => byHost[h] || null);
68
+ assert.equal(await P.isProvisionedHostname(db, 'live.example.com'), true);
69
+ assert.equal(await P.isProvisionedHostname(db, 'mid.example.com'), true);
70
+ assert.equal(await P.isProvisionedHostname(db, 'gone.example.com'), false);
71
+ assert.equal(await P.isProvisionedHostname(db, 'req.example.com'), false);
72
+ assert.equal(await P.isProvisionedHostname(db, 'err.example.com'), false);
73
+ assert.equal(await P.isProvisionedHostname(db, 'down.example.com'), false);
74
+ assert.equal(await P.isProvisionedHostname(db, 'unknown.example.com'), false);
75
+ });
76
+
77
+ await ta('normalizes the SNI before matching, and never queries a malformed host (fail-closed, no DB hit)', async () => {
78
+ let queried = 0;
79
+ const db = { query: async (_sql, params) => {
80
+ queried++;
81
+ return { rows: params[0] === 'live.example.com' ? [{ status: 'active' }] : [] };
82
+ } };
83
+ assert.equal(await P.isProvisionedHostname(db, 'LIVE.example.com.:443'), true, 'normalized SNI matches');
84
+ const before = queried;
85
+ assert.equal(await P.isProvisionedHostname(db, 'com'), false);
86
+ assert.equal(await P.isProvisionedHostname(db, ''), false);
87
+ assert.equal(queried, before, 'no query issued for a malformed host');
88
+ });
89
+
90
+ console.log('\nmakeAskRateLimiter (tls-check ask rate limit, P4 SEC review of task 1945):');
91
+
92
+ function fakeRes() {
93
+ return {
94
+ _status: 200, _json: null, _headers: {},
95
+ set(k, v) { this._headers[k] = v; return this; },
96
+ status(c) { this._status = c; return this; },
97
+ json(b) { this._json = b; return this; },
98
+ };
99
+ }
100
+ function call(mw, ip) {
101
+ let nexted = false;
102
+ const res = fakeRes();
103
+ mw({ ip }, res, () => { nexted = true; });
104
+ return { nexted, res };
105
+ }
106
+
107
+ t('allows up to `max` per window, then 429s with Retry-After', () => {
108
+ let clock = 1_000_000;
109
+ const mw = RL.makeAskRateLimiter({ windowMs: 60_000, max: 3, now: () => clock });
110
+ for (let i = 0; i < 3; i++) {
111
+ const { nexted, res } = call(mw, '1.2.3.4');
112
+ assert.equal(nexted, true, `request ${i + 1} within cap passes`);
113
+ assert.equal(res._status, 200);
114
+ }
115
+ const over = call(mw, '1.2.3.4');
116
+ assert.equal(over.nexted, false, 'over-cap request is blocked (next not called)');
117
+ assert.equal(over.res._status, 429);
118
+ assert.ok(Number(over.res._headers['Retry-After']) >= 1, 'sets Retry-After');
119
+ assert.equal(over.res._json.error, 'rate_limited');
120
+ });
121
+
122
+ t('the window slides — after windowMs elapses, requests flow again', () => {
123
+ let clock = 0;
124
+ const mw = RL.makeAskRateLimiter({ windowMs: 1_000, max: 1, now: () => clock });
125
+ assert.equal(call(mw, 'a').nexted, true);
126
+ assert.equal(call(mw, 'a').nexted, false, 'second within the window is blocked');
127
+ clock += 1_001; // step past the window
128
+ assert.equal(call(mw, 'a').nexted, true, 'allowed again after the window slides');
129
+ });
130
+
131
+ t('per-IP buckets are independent', () => {
132
+ let clock = 5;
133
+ const mw = RL.makeAskRateLimiter({ windowMs: 60_000, max: 1, now: () => clock });
134
+ assert.equal(call(mw, 'ip-a').nexted, true);
135
+ assert.equal(call(mw, 'ip-a').nexted, false, 'ip-a is capped');
136
+ assert.equal(call(mw, 'ip-b').nexted, true, 'ip-b has its own bucket');
137
+ });
138
+
139
+ console.log('\nP1 pure helpers (sanity):');
140
+
141
+ t('isValidSlug basic contract', () => {
142
+ assert.equal(P.isValidSlug('mercury'), true);
143
+ assert.equal(P.isValidSlug('a'), false); // too short
144
+ assert.equal(P.isValidSlug('-x'), false); // leading dash
145
+ assert.equal(P.isValidSlug('a--b'), false); // double dash
146
+ });
147
+
148
+ console.log('\nGitHub App Manifest — pure builders (task 2080, ADR 0133):');
149
+
150
+ t('githubAppName prefers the DOMAIN (unique + reads on the consent screen)', () => {
151
+ // The domain is globally unique, so "demo" never collides once named after its host.
152
+ assert.equal(P.githubAppName({ domain: 'demo.cloudbongos.com', brand: 'Cloud Bongos', project: 'demo' }), 'demo.cloudbongos.com');
153
+ assert.equal(P.githubAppName({ domain: 'Mercury.Example.com.:443', brand: 'X', project: 'mercury' }), 'mercury.example.com'); // normalized (case/port/dot)
154
+ });
155
+
156
+ t('githubAppName falls back to brand-qualified project when there is no usable domain', () => {
157
+ // no domain → "<brand> - <project>", still collision-resistant vs a bare slug
158
+ assert.equal(P.githubAppName({ brand: 'Cloud Bongos', project: 'demo' }), 'Cloud Bongos - demo');
159
+ // a domain over GitHub's 34-char limit → fall back to brand-qualified project
160
+ assert.equal(P.githubAppName({ domain: 'a-really-long-subdomain.enterprise.example.com', brand: 'Cloud Bongos', project: 'demo' }), 'Cloud Bongos - demo');
161
+ assert.equal(P.githubAppName({ brand: '', project: 'mercury' }), 'mercury'); // no brand → project
162
+ assert.equal(P.githubAppName({ brand: 'Cloud Bongos', project: '' }), 'Cloud Bongos'); // no project → brand
163
+ assert.equal(P.githubAppName({}), 'Cloud Bongos'); // last-resort default
164
+ const long = P.githubAppName({ brand: 'Cloud Bongos', project: 'x'.repeat(50) });
165
+ assert.ok(long.length <= 34 && long.startsWith('Cloud Bongos - '), `capped + brand kept: ${long}`);
166
+ });
167
+
168
+ t('buildGithubAppManifest points redirect at the MOTHERSHIP, callbacks at the INSTANCE, minimal perms, domain-named', () => {
169
+ const m = P.buildGithubAppManifest({
170
+ instanceName: 'mercury', slug: 'mercury', brand: 'Cloud Bongos',
171
+ instanceDomain: 'mercury.example.com', mothershipOrigin: 'https://cloudbongos.com',
172
+ });
173
+ assert.equal(m.name, 'mercury.example.com'); // the App is named after its own domain
174
+ assert.equal(m.url, 'https://mercury.example.com');
175
+ // redirect_url = the control-plane callback (where GitHub returns the code)
176
+ assert.equal(m.redirect_url, 'https://cloudbongos.com/api/bongos/provisioning/github-app/callback');
177
+ // callback_urls = the INSTANCE's own sign-in callback, BOTH prefixes for robustness
178
+ assert.deepEqual(m.callback_urls, [
179
+ 'https://mercury.example.com/api/gds/auth/web/callback',
180
+ 'https://mercury.example.com/api/bongos/auth/web/callback',
181
+ ]);
182
+ // sign-in needs no repo/account permissions; no active webhook; not public
183
+ assert.deepEqual(m.default_permissions, {});
184
+ assert.equal(m.hook_attributes.active, false);
185
+ assert.equal(m.public, false);
186
+ });
187
+
188
+ t('buildGithubAppManifest tolerates a trailing slash on the mothership origin', () => {
189
+ const m = P.buildGithubAppManifest({ instanceName: 'M', slug: 'm', instanceDomain: 'm.example.com', mothershipOrigin: 'https://cloudbongos.com/' });
190
+ assert.equal(m.redirect_url, 'https://cloudbongos.com/api/bongos/provisioning/github-app/callback');
191
+ });
192
+
193
+ t('manifestPostUrl: personal vs org, state url-encoded', () => {
194
+ assert.equal(P.manifestPostUrl({ account: 'personal', state: 'abc' }), 'https://github.com/settings/apps/new?state=abc');
195
+ assert.equal(P.manifestPostUrl({ account: 'org', orgLogin: 'acme', state: 'a b' }), 'https://github.com/organizations/acme/settings/apps/new?state=a%20b');
196
+ // org requested but no org login → falls back to personal (never a broken URL)
197
+ assert.equal(P.manifestPostUrl({ account: 'org', state: 'x' }), 'https://github.com/settings/apps/new?state=x');
198
+ });
199
+
200
+ console.log('\nGitHub App Manifest — data access (fake { query }):');
201
+
202
+ await ta('attachManifestCode is single-use + TTL-guarded (only a pending, un-expired row advances)', async () => {
203
+ const seen = [];
204
+ const db = { query: async (sql, params) => {
205
+ seen.push({ sql, params });
206
+ // Emulate the guarded UPDATE ... WHERE status='pending' AND expires_at > now():
207
+ // the fake returns a row only when the caller's id is the "fresh" one.
208
+ if (/UPDATE provisioning_oauth_manifests/.test(sql) && /status = 'code_received'/.test(sql)) {
209
+ return { rows: params[0] === 1 ? [{ id: 1, status: 'code_received', github_code: params[1] }] : [] };
210
+ }
211
+ return { rows: [] };
212
+ } };
213
+ const fresh = await P.attachManifestCode(db, 1, 'CODE123');
214
+ assert.ok(fresh && fresh.status === 'code_received' && fresh.github_code === 'CODE123');
215
+ const stale = await P.attachManifestCode(db, 2, 'CODE456'); // wrong/expired → no row
216
+ assert.equal(stale, null);
217
+ // the guard (status + expiry) lives in the SQL, not JS — assert it's actually there
218
+ assert.match(seen[0].sql, /status = 'pending' AND expires_at > now\(\)/);
219
+ });
220
+
221
+ await ta('claimNextOAuthManifest flips code_received→converting, gated on an ACTIVE instance, SKIP LOCKED', async () => {
222
+ let sql = '';
223
+ const db = { query: async (q) => { sql = q; return { rows: [{ id: 7, status: 'converting' }] }; } };
224
+ const row = await P.claimNextOAuthManifest(db);
225
+ assert.equal(row.id, 7);
226
+ assert.match(sql, /SET status = 'converting'/);
227
+ assert.match(sql, /FOR UPDATE OF m SKIP LOCKED/);
228
+ assert.match(sql, /m\.status = 'code_received' AND i\.status = 'active'/);
229
+ });
230
+
231
+ console.log(`\nprovisioning module: ${passed} passed, ${failed} failed`);
232
+ process.exit(failed ? 1 : 0);
File without changes
@@ -146,10 +146,19 @@ function redactHighEntropy(text) {
146
146
  const ENV_SECRET_KEY_RE =
147
147
  /(?:api[_-]?key|secret|token|password|passwd|pwd|passphrase|access[_-]?key|private[_-]?key|client[_-]?secret|credential|cookie)/i;
148
148
  const ENV_SECRET_PREFIX_RE = /^(?:gds_|pms_|mac_|apple|anthropic|stripe|cf_|cloudflare|do_token|digitalocean)/i;
149
+ // Benign shell/runtime vars whose NAME trips a secret-word substring but whose VALUE is
150
+ // never a credential. PWD and OLDPWD contain "pwd" (the password alternative in
151
+ // ENV_SECRET_KEY_RE), yet hold a working-directory PATH — so without this exclusion a
152
+ // legitimate publish fails closed whenever that path appears verbatim in a functional file
153
+ // (e.g. a /Users/<name> anti-pattern comment), passing or failing purely on the operator's
154
+ // last `cd` (task 2130). Exact-match only, so real password vars (MYSQL_PWD, DB_PWD, …)
155
+ // still match below.
156
+ const ENV_BENIGN_KEY_RE = /^(?:PWD|OLDPWD)$/;
149
157
  function collectEnvSecrets(env = process.env) {
150
158
  const out = [];
151
159
  for (const [k, v] of Object.entries(env || {})) {
152
160
  if (typeof v !== 'string' || v.length < 8) continue;
161
+ if (ENV_BENIGN_KEY_RE.test(k)) continue;
153
162
  if (ENV_SECRET_KEY_RE.test(k) || ENV_SECRET_PREFIX_RE.test(k)) out.push(v);
154
163
  }
155
164
  return out;
@@ -196,6 +205,7 @@ module.exports = {
196
205
  redactHighEntropy,
197
206
  ENV_SECRET_KEY_RE,
198
207
  ENV_SECRET_PREFIX_RE,
208
+ ENV_BENIGN_KEY_RE,
199
209
  collectEnvSecrets,
200
210
  scrubSecrets,
201
211
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.17.1",
3
+ "version": "1.17.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.17.1",
9
+ "version": "1.17.2",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.17.1",
3
+ "version": "1.17.2",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -10,7 +10,12 @@
10
10
  "exports": {
11
11
  ".": "./src/platform-server.js",
12
12
  "./serve-internal": "./src/bongos/serve-internal.js",
13
- "./module-api": "./src/module-api.js"
13
+ "./module-api": "./src/module-api.js",
14
+ "./bongos/api-path-404": "./src/bongos/api-path-404.js",
15
+ "./bongos/uptime-poller": "./src/bongos/uptime-poller.js",
16
+ "./module-loader/loader": "./src/module-loader/loader.js",
17
+ "./modules": "./src/modules.js",
18
+ "./module-seams": "./src/module-seams.js"
14
19
  },
15
20
  "engines": {
16
21
  "node": ">=22"
@@ -1,6 +1,6 @@
1
1
  # Discord integration (outbound)
2
2
 
3
- Posts Off the Boats build updates into the team Discord. This replaced the
3
+ Posts Example build updates into the team Discord. This replaced the
4
4
  Google Chat outbound broadcast (Discord Phase 2, GDS task [#607](https://example.com/builders#/task/607)).
5
5
 
6
6
  Discord is a **one-way window into the GDS** — these scripts only POST. No
@@ -143,7 +143,88 @@ function printClaimCard(task, widgetsEnabled = true) {
143
143
  } catch (_) { /* never block a claim on the card */ }
144
144
  }
145
145
 
146
- (async () => {
146
+ // task 1476: render a failed claim as GUIDANCE, not a raw object dump. The API
147
+ // returns the R01 error envelope (ADR 0116): { error: { code, message, details } }.
148
+ // Older paths returned a flat { error: 'CODE', ...fields } shape. This normalizes
149
+ // BOTH — without it `r.data.error` is now an object (never === a string), so every
150
+ // branch below silently missed and the refusal printed as `[object Object]`. Pure
151
+ // (no console / no process.exit) + exported so it can be unit-tested against the
152
+ // real envelope shapes. Returns { lines, exit }.
153
+ function formatClaimFailure(r, taskId) {
154
+ const lines = [];
155
+ const errRaw = r && r.data ? r.data.error : undefined;
156
+ const isEnvelope = errRaw !== null && typeof errRaw === 'object';
157
+ const code = isEnvelope ? errRaw.code : errRaw;
158
+ const message = isEnvelope ? errRaw.message : undefined;
159
+ // Structured fields live under .details in the envelope; fall back to the
160
+ // top-level body for the legacy flat shape (so both keep working).
161
+ const d = { ...(r && r.data ? r.data : {}), ...((isEnvelope && errRaw.details) || {}) };
162
+
163
+ // #766: a generic onboarding stage gate (kept rendering cleanly, not as raw JSON).
164
+ if (code === 'stage_required') {
165
+ lines.push(`Task #${taskId} can't be claimed yet.`);
166
+ lines.push(` ${d.what_to_do || `Clear "${d.required_stage_label || d.required_stage}" first.`}`);
167
+ return { lines, exit: 1 };
168
+ }
169
+ // task 1476 (the core case): a session may hold at most ONE active claim
170
+ // (task 1642 / ADR 0097). This is the most common refusal and previously fell
171
+ // straight through to the raw dump — the exact "reads like you can't hold more
172
+ // than one task" complaint from idea 218. Surface it as guidance AND teach how
173
+ // to parallelize (a separate worktree/session per task, or the hall bulk-claim).
174
+ if (code === 'session_already_has_active_claim' || code === 'session_claim_check_failed') {
175
+ const n = d.active_task_id;
176
+ lines.push(n
177
+ ? `This session already holds task #${n}${d.active_task_title ? `: ${d.active_task_title}` : ''}.`
178
+ : 'This session already holds an active claim.');
179
+ lines.push(` ${d.what_to_do || (n
180
+ ? `Run /builder-ship ${n} (or /builder-release ${n}) first, or open a fresh worktree/session for this task.`
181
+ : 'Ship or release the in-flight claim, or start a fresh worktree/session.')}`);
182
+ lines.push(' One claim per session keeps each ship isolated (task 1642). To work tasks in PARALLEL, open a separate worktree/session per task — each holds its own single claim — or use the hall work board’s multi-select bulk-claim.');
183
+ return { lines, exit: 1 };
184
+ }
185
+ if (code === 'newcomer_must_ship_first') {
186
+ lines.push(`Task #${taskId} can't be claimed yet — your first work isn't shipped.`);
187
+ lines.push(` You're holding an active claim on #${d.active_task_id}: ${d.active_task_title}`);
188
+ lines.push(` ${d.what_to_do || 'Ship or release the current claim first.'}`);
189
+ return { lines, exit: 1 };
190
+ }
191
+ if (code === 'ALREADY_CLAIMED') {
192
+ lines.push(`Task #${taskId} was just claimed by another session. Try a different task.`);
193
+ return { lines, exit: 1 };
194
+ }
195
+ if (code === 'MODULE_CONFLICT') {
196
+ lines.push(`Task #${taskId} has module_key='${d.module_key}', but another active claim already owns that module:`);
197
+ for (const t of d.conflictTaskTitles || []) lines.push(` - ${t}`);
198
+ lines.push('Two tasks in the same module cannot run concurrently. Ship or release the in-flight claim first.');
199
+ return { lines, exit: 1 };
200
+ }
201
+ if (code === 'TOUCHES_CONFLICT') {
202
+ lines.push(`Task #${taskId} touches files currently in flight on another claim:`);
203
+ for (const t of d.conflictTaskTitles || []) lines.push(` - ${t}`);
204
+ lines.push('Pick a task with disjoint touches[], or run it in a separate session once the other ships.');
205
+ return { lines, exit: 1 };
206
+ }
207
+ if (code === 'TASK_NOT_READY') {
208
+ lines.push(`Task #${taskId} is not ready (current status: ${d.currentStatus}).`);
209
+ if (d.hint) lines.push(` ${d.hint}`);
210
+ return { lines, exit: 1 };
211
+ }
212
+ if (code === 'TASK_NOT_FOUND') {
213
+ lines.push(`Task #${taskId} not found — check the id (run /builder-start to list claimable tasks).`);
214
+ return { lines, exit: 1 };
215
+ }
216
+ if (code === 'DEPS_NOT_SHIPPED') {
217
+ lines.push(`Task #${taskId} can't be claimed yet — these dependencies are not shipped:`);
218
+ for (const dep of d.deps || []) lines.push(` - #${dep.id} [${dep.status}] ${dep.title}`);
219
+ lines.push('Ship those first (or release them) and try again.');
220
+ return { lines, exit: 1 };
221
+ }
222
+ // Fallback — still readable: the message + code, never a raw [object Object].
223
+ lines.push(`Claim failed (${r ? r.status : '?'})${code ? ` [${code}]` : ''}${message ? `: ${message}` : ''}.`);
224
+ return { lines, exit: 1 };
225
+ }
226
+
227
+ async function main() {
147
228
  await requireSession();
148
229
  const args = process.argv.slice(2);
149
230
  const taskId = Number(args[0]);
@@ -283,51 +364,20 @@ function printClaimCard(task, widgetsEnabled = true) {
283
364
  printClaimCard(task, widgetsEnabled);
284
365
  return;
285
366
  }
286
- // #766: the newcomer claim gate is now just the one-claim-at-a-time rule
287
- // below. A generic stage_required (any future onboarding gate) still renders
288
- // cleanly rather than as a raw API error.
289
- if (r.data?.error === 'stage_required') {
290
- console.error(`Task #${taskId} can't be claimed yet.`);
291
- console.error(` ${r.data.what_to_do || `Clear "${r.data.required_stage_label || r.data.required_stage}" first.`}`);
292
- process.exit(1);
293
- }
294
- if (r.data?.error === 'newcomer_must_ship_first') {
295
- console.error(`Task #${taskId} can't be claimed yet — your first work isn't shipped.`);
296
- console.error(` You're holding an active claim on #${r.data.active_task_id}: ${r.data.active_task_title}`);
297
- console.error(` ${r.data.what_to_do || 'Ship or release the current claim first.'}`);
298
- process.exit(1);
299
- }
300
- if (r.data?.error === 'ALREADY_CLAIMED') {
301
- console.error(`Task #${taskId} was just claimed by another session. Try a different task.`);
302
- process.exit(1);
303
- }
304
- if (r.data?.error === 'MODULE_CONFLICT') {
305
- console.error(`Task #${taskId} has module_key='${r.data.module_key}', but another active claim already owns that module:`);
306
- for (const t of r.data.conflictTaskTitles || []) console.error(` - ${t}`);
307
- console.error('Two tasks in the same module cannot run concurrently. Ship or release the in-flight claim first.');
308
- process.exit(1);
309
- }
310
- if (r.data?.error === 'TOUCHES_CONFLICT') {
311
- console.error(`Task #${taskId} touches files currently in flight on another claim:`);
312
- for (const t of r.data.conflictTaskTitles || []) console.error(` - ${t}`);
313
- console.error('Pick a task with disjoint touches[].');
314
- process.exit(1);
315
- }
316
- if (r.data?.error === 'TASK_NOT_READY') {
317
- console.error(`Task #${taskId} is not ready (current status: ${r.data.currentStatus}).`);
318
- process.exit(1);
319
- }
320
- if (r.data?.error === 'DEPS_NOT_SHIPPED') {
321
- console.error(`Task #${taskId} can't be claimed yet — these dependencies are not shipped:`);
322
- for (const d of r.data.deps || []) {
323
- console.error(` - #${d.id} [${d.status}] ${d.title}`);
324
- }
325
- console.error('Ship those first (or release them) and try again.');
367
+ // Any non-201 comes here. formatClaimFailure normalizes the R01 error envelope
368
+ // and renders the refusal as guidance (task 1476) — one place, unit-tested.
369
+ const failure = formatClaimFailure(r, taskId);
370
+ for (const line of failure.lines) console.error(line);
371
+ process.exit(failure.exit);
372
+ }
373
+
374
+ // Only auto-run when invoked directly (`node claim.js …`); a `require()` (the
375
+ // unit test) just imports formatClaimFailure without executing the claim flow.
376
+ if (require.main === module) {
377
+ main().catch((err) => {
378
+ console.error('fatal:', err);
326
379
  process.exit(1);
327
- }
328
- console.error(`Claim failed (${r.status}):`, r.data);
329
- process.exit(1);
330
- })().catch((err) => {
331
- console.error('fatal:', err);
332
- process.exit(1);
333
- });
380
+ });
381
+ }
382
+
383
+ module.exports = { formatClaimFailure };
@@ -15,8 +15,9 @@
15
15
  // copy+transform. hooks/ + settings.json are TOPOLOGY (PROTECTED_TOPOLOGY_EXCLUDE) → NOT in the
16
16
  // package; this module copies+transforms them when they ARE present at the core root (today's single
17
17
  // checkout, and the core repo itself), and no-ops with a note when they aren't (an installed package).
18
- // Rewriting hook *.js internals + shipping a topology STARTER for a package-only install is a
19
- // follow-up (idea captured by task 1883) — this lands the skills materialization + the transform.
18
+ // Hooks self-resolve their core require()s at RUNTIME via the copied _core-resolve.js sibling
19
+ // (task 2139, the former W3 follow-up), so no require-path rewriting happens here; shipping a
20
+ // topology STARTER for a package-only install (hooks absent at the core root) stays a follow-up (task 1883).
20
21
  //
21
22
  // SELF-COPY GUARD. When the instance dir IS the core root (running init inside the core checkout /
22
23
  // OTB today), there is nothing to materialize — a self-copy would be a no-op at best and corrupting
@@ -70,9 +71,9 @@ function bongosify(text, verbs = VERBS) {
70
71
  // branding objects, so no source host survives. Focused + FULL-HOST (a subdomain
71
72
  // like demo.cloudbongos.com is preserved), fail-open (a missing field drops that
72
73
  // one rule), a no-op when source == target, and it touches ONLY the copied output
73
- // (never the running source hooks). This does NOT rewrite hooks' internal
74
- // require('scripts/gds/…') paths for a package-only install — that stays the W3
75
- // follow-up; this lands the origin/config-dir neutralization.
74
+ // (never the running source hooks). It does NOT rewrite hooks' internal
75
+ // require('scripts/gds/…') paths — those self-resolve at runtime via the copied
76
+ // _core-resolve.js sibling (task 2139); this rule lands the origin/config-dir neutralization.
76
77
 
77
78
  function reEsc(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
78
79
  function hostOf(origin) { try { return new URL(String(origin)).host || null; } catch { return null; } }
@@ -214,7 +215,7 @@ function transformSettings(srcPath) {
214
215
  const HOOK_TEXT_EXT = new Set(['.js', '.mjs', '.cjs', '.json', '.sh', '.md', '.txt']); // hooks are .js — rebrand them too
215
216
 
216
217
  function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite = true, log = () => {} }) {
217
- const summary = { skipped: false, skills: 0, settings: false, hooks: 0, skippedFiles: [], excludedSkills: [], rebrandRules: 0, claudeMd: false, notes: [] };
218
+ const summary = { skipped: false, skills: 0, settings: false, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], rebrandRules: 0, claudeMd: false, notes: [] };
218
219
  if (path.resolve(instanceDir) === path.resolve(coreRoot)) {
219
220
  summary.skipped = true;
220
221
  summary.notes.push('instance dir is the core root — nothing to materialize (byte-identical no-op).');
@@ -273,8 +274,8 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
273
274
  }
274
275
 
275
276
  // 3. hooks — topology; copied WITH an identity rebrand (their .js internals carried
276
- // the founder origin/config-dir). require('scripts/gds/…') path rewriting for a
277
- // package-only install stays the W3 follow-up.
277
+ // the founder origin/config-dir). Their core require()s self-resolve at runtime via
278
+ // the copied _core-resolve.js sibling (task 2139) — no path rewriting needed here.
278
279
  const hooksRes = copyTreeTransformed(path.join(srcClaude, 'hooks'), path.join(destClaude, 'hooks'), {
279
280
  dryRun, overwrite, transform: rebrand, textExt: HOOK_TEXT_EXT,
280
281
  });
@@ -284,7 +285,27 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
284
285
  summary.notes.push('.claude/hooks not present at the core root (topology, excluded from the package) — provide them from a starter.');
285
286
  } else {
286
287
  log(` ${dryRun ? '[dry-run] would materialize' : '✓ materialized'} ${hooksRes.files} hook file(s) → .claude/hooks/ (founder identity rebranded)${left((hooksRes.skipped || []).length)}`);
287
- summary.notes.push('hooks: founder origin/config-dir rebranded to this instance; their internal `require(scripts/gds/…)` paths are NOT yet rewritten to resolve inside the package (W3 follow-up).');
288
+ summary.notes.push('hooks: founder origin/config-dir rebranded to this instance; their internal `require(scripts/gds/…)` paths self-resolve inside the package at runtime via the copied _core-resolve.js sibling (task 2139).');
289
+ }
290
+
291
+ // 3b. scheduled-tasks — the cron routines (System 4 dispatcher, ADR 0078; task 2138). TOPOLOGY
292
+ // like hooks (NOT in the package — PROTECTED_TOPOLOGY_EXCLUDE), so materialized by copy when
293
+ // present at the core root and a no-op-with-note in a package-only install. Bodies (.md) are
294
+ // bongosified so a judgment routine's `node scripts/gds/x.js` model-instructions become
295
+ // `bongos exec …`; the deterministic routines' `.claude/scheduled-tasks/<x>.js` step commands
296
+ // are NOT matched by bongosify and stay as-is. Companion .js are rebranded but keep their
297
+ // paths: they resolve core require()s at runtime via the copied _core-resolve.js sibling
298
+ // (task 2139), and run-routine.js finds the routines under resolveInstanceRoot() post-split.
299
+ const routinesRes = copyTreeTransformed(path.join(srcClaude, 'scheduled-tasks'), path.join(destClaude, 'scheduled-tasks'), {
300
+ dryRun, overwrite, transform: (t) => rebrand(bongosify(t)), textExt: HOOK_TEXT_EXT,
301
+ });
302
+ summary.scheduledTasks = routinesRes.files;
303
+ for (const rel of routinesRes.skipped || []) summary.skippedFiles.push(`scheduled-tasks/${rel}`);
304
+ if (!routinesRes.present) {
305
+ summary.notes.push('.claude/scheduled-tasks not present at the core root (topology, excluded from the package) — provide them from a starter.');
306
+ } else {
307
+ log(` ${dryRun ? '[dry-run] would materialize' : '✓ materialized'} ${routinesRes.files} scheduled-task file(s) → .claude/scheduled-tasks/ (bodies→bongos, founder identity rebranded)${left((routinesRes.skipped || []).length)}`);
308
+ summary.notes.push('scheduled-tasks: cron routines materialized; run-routine.js resolves them under resolveInstanceRoot() and their companion .js resolve core via the copied _core-resolve.js sibling (task 2138).');
288
309
  }
289
310
 
290
311
  // 4. CLAUDE.md — the project-root framing an agent reads every session (task 2104).