@bongos/core 1.19.77 → 1.19.78

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 CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.77",
6
- "core_contract": "1.19.77",
7
- "source_commit": "a20aad3a45d55f5a39b9923d17ec460bfd504630",
5
+ "core_version": "1.19.78",
6
+ "core_contract": "1.19.78",
7
+ "source_commit": "e325ccc071131d7c01dbf333d8f7cd57ae312039",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-12T22:40:06.341Z",
9
+ "built_at": "2026-08-12T22:44:08.133Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 295,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1541,
20
- "tree_sha256": "3125b6aac967f4e3e3e323f3572eee2b53307f8f0acfb7699a40202bc966e354",
20
+ "tree_sha256": "96bcb5ac17be8a28801e81faab41c95d0b6b2bfee4cd4919231a9b2e0ac6a8a9",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2582,7 +2582,7 @@
2582
2582
  {
2583
2583
  "path": "modules/dev-box/box-access.js",
2584
2584
  "mode": "0000644",
2585
- "sha256": "2afd598f687392b313dc872e73d4e6b61af27ce96d140685268ea5697955d636"
2585
+ "sha256": "8d2dc69a139a5a658866a18527efeb5d9ae989e05883fa6cbd0ea63db0ba9476"
2586
2586
  },
2587
2587
  {
2588
2588
  "path": "modules/dev-box/box-credential.js",
@@ -2612,7 +2612,7 @@
2612
2612
  {
2613
2613
  "path": "modules/dev-box/routes/box.js",
2614
2614
  "mode": "0000644",
2615
- "sha256": "a9f51c0ddc4fc8c558d0c6dde77ffcd3e1b1262a6f14f93c88e1bb3e9093538d"
2615
+ "sha256": "ba75c28d8927ebbda1482f394a0062febc6e32a52863814d00d6ad2a6667a591"
2616
2616
  },
2617
2617
  {
2618
2618
  "path": "modules/discord/CLAUDE.md",
@@ -3402,7 +3402,7 @@
3402
3402
  {
3403
3403
  "path": "modules/lifecycle/db.js",
3404
3404
  "mode": "0000644",
3405
- "sha256": "bc1526d068698e489fc2a698c04418e60097f18b56fd5c0dcdb6a6f62bbd1c81"
3405
+ "sha256": "5e7dc6c267384eb3b691f49ec82631425151b97d2208eaf342922bf46146965f"
3406
3406
  },
3407
3407
  {
3408
3408
  "path": "modules/lifecycle/done-when.js",
@@ -3437,7 +3437,7 @@
3437
3437
  {
3438
3438
  "path": "modules/lifecycle/lifecycle.js",
3439
3439
  "mode": "0000644",
3440
- "sha256": "5c0d0da5d6a8bcfb920bc432558066840e649cbc624e064dc4e55a926a3d8bb4"
3440
+ "sha256": "8f62f50c5a81d649df6b85ef7c89e57a0bf0e8877482693861a4f9cd5cfafe5a"
3441
3441
  },
3442
3442
  {
3443
3443
  "path": "modules/lifecycle/merge-lock.js",
@@ -4057,12 +4057,12 @@
4057
4057
  {
4058
4058
  "path": "package-lock.json",
4059
4059
  "mode": "0000644",
4060
- "sha256": "ffd10996e222dd7fe49aa05ad7177979e18cb7871ee3666f2cc96c9822e8075e"
4060
+ "sha256": "7829011d4ec272839d524b9e2f9449e16c18ff635378e52490091cb025cefe3f"
4061
4061
  },
4062
4062
  {
4063
4063
  "path": "package.json",
4064
4064
  "mode": "0000644",
4065
- "sha256": "3cdd1266637cfb67eed301d96a9f6414d32d98f1d4e9033e20aa6687806526c3"
4065
+ "sha256": "b179d0a0a83c292a353548cceac274dba29e8e296c62565244cc4d2fc81e6951"
4066
4066
  },
4067
4067
  {
4068
4068
  "path": "public-docs/index.html",
@@ -5687,7 +5687,7 @@
5687
5687
  {
5688
5688
  "path": "src/module-api.js",
5689
5689
  "mode": "0000644",
5690
- "sha256": "fee8a499a0208f8f825a317e5ab631684bcb2c718dde601e6eab23eda5f2728c"
5690
+ "sha256": "65dcc9e68f955842239c2c9d4f26e77c920ede6e79608347758ca7d6d71fb5e4"
5691
5691
  },
5692
5692
  {
5693
5693
  "path": "src/module-loader/loader.js",
@@ -6032,7 +6032,7 @@
6032
6032
  {
6033
6033
  "path": "tests/box_task_scope.mjs",
6034
6034
  "mode": "0000644",
6035
- "sha256": "e6cb9f94ae75fc9dd9134c85d8074bfe008eadd64eaddb0df2f1d6d851683c36"
6035
+ "sha256": "22a2dc8bdccb9900b642371b85e8cc1785ecfacaf8f7579b042951425d95d794"
6036
6036
  },
6037
6037
  {
6038
6038
  "path": "tests/box_terminal_ssrf.mjs",
@@ -203,18 +203,33 @@ function sparsePathsForScopeKeys(scopeKeys, moduleScopeMap) {
203
203
  // + passed by the route in R94) selects the surface, three ways:
204
204
  // - scopeKeys is a NON-EMPTY array → TASK scope: BASE ∪ the claims' module dirs
205
205
  // (via sparsePathsForScopeKeys + the passed-in `moduleScopeMap`).
206
- // - scopeKeys is an EMPTY array → BASE-only (a live box with no active claim /
207
- // no scoped goal — the pre-existing Bongos instance, nothing extra).
206
+ // - scopeKeys is an EMPTY array → depends on whether the builder is actually
207
+ // working (see `hasActiveClaims` below).
208
208
  // - scopeKeys is ABSENT (undefined) → the pre-ADR-0148 RANK fallback (full for
209
209
  // Metic+, else the starter sparse surface). This is the path a vanilla instance
210
210
  // with no lifecycle port takes, and it preserves the old behavior EXACTLY — so
211
211
  // shipping R93 before R94 wires the route changes nothing in production.
212
212
  //
213
+ // TASK 1002729 — the empty-array case is NOT one case, it is two, and collapsing
214
+ // them was a live defect. moduleScopeForActiveClaims returns [] both when the
215
+ // builder holds no claim AND when they hold claims whose goals declare no
216
+ // scope_modules (the goal wall is optional; only 13 of 46 goals on the live
217
+ // instance declared one). Treating the second as "no scope" handed a working
218
+ // builder a box with NO modules/ directory at all — less code than the rank
219
+ // fallback would have given a newcomer. On the live fleet that was 247 of 248
220
+ // source grants, including an Archon holding 7 active claims.
221
+ // - [] + hasActiveClaims true → RANK fallback. The builder is working; an
222
+ // undeclared goal wall is a planning gap, and it must not cripple their box.
223
+ // - [] + hasActiveClaims false → BASE-only, as before (a genuinely unscoped box
224
+ // — the pre-existing Bongos instance, nothing extra). Least privilege holds.
225
+ // `hasActiveClaims` is optional: omitted (undefined) preserves the old BASE-only
226
+ // reading, so a caller that hasn't been taught the signal is unchanged.
227
+ //
213
228
  // Returns one of:
214
229
  // { allowed:false, reason:'BUILDER_INACTIVE' }
215
230
  // { allowed:false, reason:'INSUFFICIENT_RANK', required, actual }
216
231
  // { allowed:true, scope, spec }
217
- function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, moduleScopeMap } = {}) {
232
+ function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, hasActiveClaims, moduleScopeMap } = {}) {
218
233
  if (status && status !== 'active') {
219
234
  return { allowed: false, reason: 'BUILDER_INACTIVE' };
220
235
  }
@@ -228,11 +243,19 @@ function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, sco
228
243
  }
229
244
  // Claim-driven task scope (present → the box asks for exactly its work's code).
230
245
  if (Array.isArray(scopeKeys)) {
231
- const sparsePaths = sparsePathsForScopeKeys(scopeKeys, moduleScopeMap);
232
- const scope = scopeKeys.length > 0 ? 'task' : 'base';
233
- return { allowed: true, scope, spec: { scope, mode: 'sparse', sparsePaths } };
246
+ if (scopeKeys.length > 0) {
247
+ const sparsePaths = sparsePathsForScopeKeys(scopeKeys, moduleScopeMap);
248
+ return { allowed: true, scope: 'task', spec: { scope: 'task', mode: 'sparse', sparsePaths } };
249
+ }
250
+ // Empty. A builder mid-claim whose goals declare no wall gets the rank scope,
251
+ // NOT a stripped box — the planning gap is not theirs to pay for.
252
+ if (!hasActiveClaims) {
253
+ const sparsePaths = sparsePathsForScopeKeys([], moduleScopeMap);
254
+ return { allowed: true, scope: 'base', spec: { scope: 'base', mode: 'sparse', sparsePaths } };
255
+ }
234
256
  }
235
- // No claim signal (no lifecycle port on this instance): the pre-ADR-0148 rank fallback.
257
+ // No claim signal (no lifecycle port on this instance), or claims with an
258
+ // undeclared goal wall: the pre-ADR-0148 rank fallback.
236
259
  const scope = boxScopeForRank(rank);
237
260
  return { allowed: true, scope, spec: cloneSpecForScope(scope) };
238
261
  }
@@ -444,6 +444,7 @@ module.exports = function buildBoxRouter() {
444
444
  // NEVER fail the source fetch on a scope-read hiccup — degrade to rank scope.
445
445
  const lifecycle = api.resolveOptional('lifecycle');
446
446
  let scopeKeys;
447
+ let hasActiveClaims;
447
448
  if (lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
448
449
  try {
449
450
  scopeKeys = await lifecycle.moduleScopeForActiveClaims(req.builder.id);
@@ -452,7 +453,19 @@ module.exports = function buildBoxRouter() {
452
453
  scopeKeys = undefined;
453
454
  }
454
455
  }
455
- const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, moduleScopeMap: api.moduleScopeMap });
456
+ // task 1002729 — only needed to disambiguate an EMPTY scope: no claim (BASE)
457
+ // vs claims whose goals declare no wall (rank scope). Skipped otherwise so
458
+ // the common path keeps its single query.
459
+ if (Array.isArray(scopeKeys) && scopeKeys.length === 0
460
+ && lifecycle && typeof lifecycle.activeClaimCountForBuilder === 'function') {
461
+ try {
462
+ hasActiveClaims = (await lifecycle.activeClaimCountForBuilder(req.builder.id)) > 0;
463
+ } catch (claimErr) {
464
+ console.error('[gds] source-access claim-count read (non-fatal, → BASE)', claimErr && claimErr.message ? claimErr.message : claimErr);
465
+ hasActiveClaims = undefined;
466
+ }
467
+ }
468
+ const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap });
456
469
  const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
457
470
  const auditDeny = (reason, extra) =>
458
471
  box && boxAccess.recordSourceEvent(pool, {
@@ -548,6 +561,7 @@ module.exports = function buildBoxRouter() {
548
561
  // (optional lifecycle port; degrade to rank scope on any hiccup).
549
562
  const lifecycle = api.resolveOptional('lifecycle');
550
563
  let scopeKeys;
564
+ let hasActiveClaims;
551
565
  if (lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
552
566
  try {
553
567
  scopeKeys = await lifecycle.moduleScopeForActiveClaims(req.builder.id);
@@ -556,7 +570,18 @@ module.exports = function buildBoxRouter() {
556
570
  scopeKeys = undefined;
557
571
  }
558
572
  }
559
- const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, moduleScopeMap: api.moduleScopeMap });
573
+ // task 1002729 — same disambiguation as source-access, so this read-only
574
+ // companion can never report a scope the real fetch wouldn't grant.
575
+ if (Array.isArray(scopeKeys) && scopeKeys.length === 0
576
+ && lifecycle && typeof lifecycle.activeClaimCountForBuilder === 'function') {
577
+ try {
578
+ hasActiveClaims = (await lifecycle.activeClaimCountForBuilder(req.builder.id)) > 0;
579
+ } catch (claimErr) {
580
+ console.error('[gds] /box/scope claim-count read (non-fatal, → BASE)', claimErr && claimErr.message ? claimErr.message : claimErr);
581
+ hasActiveClaims = undefined;
582
+ }
583
+ }
584
+ const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap });
560
585
  if (!decision.allowed) {
561
586
  return res.fail('SOURCE_ACCESS_REVOKED', 403, {
562
587
  reason: decision.reason,
@@ -4756,6 +4756,29 @@ async function moduleScopeForActiveClaims(builderId, deps = {}) {
4756
4756
  return rows.map((r) => r.module_key);
4757
4757
  }
4758
4758
 
4759
+ // Task 1002729 — the companion signal moduleScopeForActiveClaims cannot carry.
4760
+ // That query INNER JOINs goals and unnests scope_modules, so it returns [] for
4761
+ // BOTH "this builder holds no claim" and "this builder holds claims whose goals
4762
+ // declare no scope_modules". Those must not be treated alike: the first is a
4763
+ // genuinely unscoped box (BASE-only is right), the second is a builder actively
4764
+ // working who would be handed a box with no modules/ at all. Live evidence for
4765
+ // why this matters: 247 of 248 source grants on the cloudbongos fleet resolved to
4766
+ // BASE, including an Archon holding 7 active claims — every one of those claims
4767
+ // sat on a goal with scope_modules = {}. Counts CLAIMS, deliberately not joining
4768
+ // goals, so it stays true regardless of how the goal wall is (or isn't) declared.
4769
+ async function activeClaimCountForBuilder(builderId, deps = {}) {
4770
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
4771
+ const id = Number(builderId);
4772
+ if (!Number.isInteger(id)) {
4773
+ throw new Error('activeClaimCountForBuilder: builderId must be numeric');
4774
+ }
4775
+ const { rows } = await activePool.query(
4776
+ `SELECT count(*)::int AS n FROM claims WHERE builder_id = $1 AND released_at IS NULL`,
4777
+ [id]
4778
+ );
4779
+ return rows.length ? Number(rows[0].n) : 0;
4780
+ }
4781
+
4759
4782
  // task 1642 — every active claim bound to a given CLI session. "Active" is the
4760
4783
  // claims-table convention (released_at IS NULL); there is NO `status` column on
4761
4784
  // claims (a common misread — the task's status lives on `tasks`). Keyed on
@@ -5625,6 +5648,7 @@ module.exports = {
5625
5648
  getClaimPublishedSha, // task 1547 — read the published tip for the reconcile gate
5626
5649
  getActiveClaimsForBuilder,
5627
5650
  moduleScopeForActiveClaims, // goal 1000051 / R92 — dev-box task-scoped source access (ADR 0148 §2)
5651
+ activeClaimCountForBuilder, // task 1002729 — distinguishes "no claim" from "claim with an empty goal wall"
5628
5652
  getActiveClaimsForSession,
5629
5653
  getLastClaimHolder, // --regrade ownership guard (#678)
5630
5654
  getTaskGrade, // V4.R08 — diff-hash reuse check before grading
@@ -65,4 +65,7 @@ module.exports = {
65
65
  // resolver (goal 1000051 / R92, ADR 0148 §2). Keys only; box-access maps them to
66
66
  // git sparse-checkout globs through the doorway `moduleScopeMap`.
67
67
  moduleScopeForActiveClaims: (...a) => db.moduleScopeForActiveClaims(...a),
68
+ // task 1002729 — the "is this builder actually working right now" signal that
69
+ // lets box-access tell an unscoped box from a crippled one (see db.js).
70
+ activeClaimCountForBuilder: (...a) => db.activeClaimCountForBuilder(...a),
68
71
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.77",
3
+ "version": "1.19.78",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.77",
9
+ "version": "1.19.78",
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.19.77",
3
+ "version": "1.19.78",
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",
package/src/module-api.js CHANGED
@@ -581,8 +581,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
581
581
  // landed since 1.19.75 with no explicit bump. run 31637719876. (task 1002620)
582
582
  // 1.19.77 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
583
583
  // landed since 1.19.76 with no explicit bump. run 31647860631. (task 1002620)
584
+ // 1.19.78 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
585
+ // landed since 1.19.77 with no explicit bump. run 31648127800. (task 1002620)
584
586
  // ---------------------------------------------------------------------------
585
- const CORE_VERSION = '1.19.77'; // CI auto-patch carrier (ADR 0161); see changelog above
587
+ const CORE_VERSION = '1.19.78'; // CI auto-patch carrier (ADR 0161); see changelog above
586
588
 
587
589
  // A namespaced logger so a module's log lines are attributable + consistent.
588
590
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -96,4 +96,74 @@ await test('an inactive builder is denied even with claims in scope', async () =
96
96
  assert.equal(inactive.decision.reason, 'BUILDER_INACTIVE');
97
97
  });
98
98
 
99
+ // =========================================================================
100
+ // Task 1002729 — an EMPTY scope is two different situations, not one
101
+ // =========================================================================
102
+ // The live defect: moduleScopeForActiveClaims returns [] both for "holds no
103
+ // claim" and for "holds claims whose goals declare no scope_modules". Reading
104
+ // both as BASE handed working builders a box with no modules/ at all — 247 of
105
+ // 248 grants on the live fleet, including an Archon with 7 active claims.
106
+
107
+ // A pool that answers BOTH queries the resolver pair now issues: the
108
+ // claims→goals→scope_modules unnest, and the plain active-claim count.
109
+ function poolReturningWithClaims(moduleKeyRows, claimCount) {
110
+ const c = makeSqlAwareClient((s) => {
111
+ if (/FROM claims c/.test(s) && /unnest\(g\.scope_modules\)/.test(s)) return { rows: moduleKeyRows };
112
+ if (/count\(\*\)/.test(s) && /FROM claims/.test(s)) return { rows: [{ n: claimCount }] };
113
+ return null;
114
+ });
115
+ return { query: (sql, params) => c._client.query(sql, params), connect: c.connect, queries: c._client.queries };
116
+ }
117
+
118
+ async function specForClaimsWithCount({ builderId = 3, rank = 'metic', status = 'active', moduleKeyRows, claimCount }) {
119
+ const pool = poolReturningWithClaims(moduleKeyRows, claimCount);
120
+ const scopeKeys = await db.moduleScopeForActiveClaims(builderId, { pool });
121
+ const hasActiveClaims = (await db.activeClaimCountForBuilder(builderId, { pool })) > 0;
122
+ return { scopeKeys, hasActiveClaims, decision: access.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: scopeMap }) };
123
+ }
124
+
125
+ await test('claims held but every goal wall empty → RANK scope, never a stripped box', async () => {
126
+ // Exactly Lars's live shape: 7 active claims, all on goals with scope_modules={}.
127
+ const { scopeKeys, hasActiveClaims, decision } = await specForClaimsWithCount({
128
+ rank: 'archon', moduleKeyRows: [], claimCount: 7,
129
+ });
130
+ assert.deepEqual(scopeKeys, [], 'the union query still yields nothing — the goals declare no wall');
131
+ assert.equal(hasActiveClaims, true, 'but the builder IS working');
132
+ assert.equal(decision.scope, 'full', 'an Archon mid-claim gets the full surface, not base');
133
+ assert.equal(decision.spec.mode, 'full');
134
+ assert.notEqual(decision.scope, 'base', 'the regression this test exists to prevent');
135
+ });
136
+
137
+ await test('no claims at all → BASE-only is preserved (least privilege holds)', async () => {
138
+ const { hasActiveClaims, decision } = await specForClaimsWithCount({
139
+ rank: 'archon', moduleKeyRows: [], claimCount: 0,
140
+ });
141
+ assert.equal(hasActiveClaims, false);
142
+ assert.equal(decision.scope, 'base', 'an idle box is still not handed the repo');
143
+ assert.deepEqual(decision.spec.sparsePaths, [...BASE]);
144
+ });
145
+
146
+ await test('a declared goal wall still wins over the rank fallback', async () => {
147
+ // Claims AND a wall → task scope, exactly as before; the fix must not widen this.
148
+ const { decision } = await specForClaimsWithCount({
149
+ rank: 'archon', moduleKeyRows: [{ module_key: 'status-ui' }], claimCount: 2,
150
+ });
151
+ assert.equal(decision.scope, 'task', 'a real wall is still honoured, not overridden by rank');
152
+ assert.deepEqual(decision.spec.sparsePaths, [...BASE, 'modules/status-ui']);
153
+ });
154
+
155
+ await test('a Thetes mid-claim with no wall gets STARTER, not full — rank still bounds it', async () => {
156
+ const { decision } = await specForClaimsWithCount({
157
+ rank: 'thetes', moduleKeyRows: [], claimCount: 1,
158
+ });
159
+ assert.equal(decision.scope, 'starter', 'the fallback is rank-bounded; it is not a blanket widen');
160
+ });
161
+
162
+ await test('activeClaimCountForBuilder rejects a non-numeric builder id', async () => {
163
+ await assert.rejects(
164
+ () => db.activeClaimCountForBuilder('not-a-number', { pool: poolReturningWithClaims([], 0) }),
165
+ /must be numeric/,
166
+ );
167
+ });
168
+
99
169
  summary();