@bongos/core 1.19.711 → 1.19.712

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.711",
6
- "core_contract": "1.19.711",
7
- "source_commit": "51bef34ef27a7d94e516e2b1d10623eeebaf80a4",
5
+ "core_version": "1.19.712",
6
+ "core_contract": "1.19.712",
7
+ "source_commit": "e848d50ff42905384b1e3df672f2dfd3f6cebb23",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-13T07:20:01.517Z",
9
+ "built_at": "2026-09-13T07:31:19.809Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 478,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2649,
20
- "tree_sha256": "15e6f8276b441266c13fefe89f515bf01b563021cd0be63afe1d36a9cc21ee5c",
20
+ "tree_sha256": "19c186cbca6bdf1dd7a73d69676cf9f52d37a5bd1dc25ed9eae3a46bdec158d2",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2807,7 +2807,7 @@
2807
2807
  {
2808
2808
  "path": "docs/module-api-changelog.md",
2809
2809
  "mode": "0000644",
2810
- "sha256": "aaf80c762f49609d71b0aafa23ac9b278a869d2f76e3b3901fee41d65956dd70"
2810
+ "sha256": "90cfe161bdbb28b563fe34f367c2e5700149f07e2cec5f783f5db8bb9ea4b3db"
2811
2811
  },
2812
2812
  {
2813
2813
  "path": "docs/modules-contract.md",
@@ -7817,12 +7817,12 @@
7817
7817
  {
7818
7818
  "path": "package-lock.json",
7819
7819
  "mode": "0000644",
7820
- "sha256": "048c83c4d1ec0e681d20c4c8da8b65a96e17ddbf88042082e109a0b0fd52e287"
7820
+ "sha256": "0cdb4130832c4680860dc1687b17e496d4e3a6ff963898690d8b7301c4040835"
7821
7821
  },
7822
7822
  {
7823
7823
  "path": "package.json",
7824
7824
  "mode": "0000644",
7825
- "sha256": "098001e183920a73e0417d67b50b869c8d381a6d76ef884933d6872fb66e063d"
7825
+ "sha256": "3c89b0fe35a5787a494119123aa0ba2a6e55f2428dfd912a208762e21743b71d"
7826
7826
  },
7827
7827
  {
7828
7828
  "path": "public-docs/index.html",
@@ -8762,7 +8762,7 @@
8762
8762
  {
8763
8763
  "path": "scripts/gds/sandbox-stage.js",
8764
8764
  "mode": "0000644",
8765
- "sha256": "583373b1d72c0a98aaafa0b57ecfcb1011fa2df9109fe312fb2f1c70234ae0d8"
8765
+ "sha256": "9d7911da631e8da6c72875a6385502f4dfc0d2d6160ed95d56aa3f25c3f86878"
8766
8766
  },
8767
8767
  {
8768
8768
  "path": "scripts/gds/search-ingest-db.js",
@@ -9602,7 +9602,7 @@
9602
9602
  {
9603
9603
  "path": "src/module-api.js",
9604
9604
  "mode": "0000644",
9605
- "sha256": "f53e83493d825804b8b4813d3c22b649d640757d6d10ec5e0d7fd70101510a2a"
9605
+ "sha256": "aa71f9f73709ca7b062e9bb5994bfb2fa7d586aa3cca4e89daa74a3204a0db15"
9606
9606
  },
9607
9607
  {
9608
9608
  "path": "src/module-loader/catalog.js",
@@ -12072,7 +12072,7 @@
12072
12072
  {
12073
12073
  "path": "tests/profile_route.mjs",
12074
12074
  "mode": "0000644",
12075
- "sha256": "ab6881bf65742f82dcb748daa5b8665c3a852a8fcc5aedb0a7040cafd6a3bdf4"
12075
+ "sha256": "ef2a58d60f561da1e025cf0958bdcb66072545158a68f3e41b4bc683afbed9d6"
12076
12076
  },
12077
12077
  {
12078
12078
  "path": "tests/profile_ui_cross_project.mjs",
@@ -12492,7 +12492,7 @@
12492
12492
  {
12493
12493
  "path": "tests/sandbox_stage.mjs",
12494
12494
  "mode": "0000644",
12495
- "sha256": "9942d11ee2f72c23c648961d15e49375e5b030e2346f49050bef7238ca369109"
12495
+ "sha256": "3873f8b93e78f026dd0dd6d8cdaf3aa68e73b3f9ce95d47bf8415cf242d42471"
12496
12496
  },
12497
12497
  {
12498
12498
  "path": "tests/scouting_owner_access_and_reach.mjs",
@@ -1881,5 +1881,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1881
1881
  landed since 1.19.709 with no explicit bump. run 34744369035. (task 1002620)
1882
1882
  1.19.711 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1883
1883
  landed since 1.19.710 with no explicit bump. run 34744940181. (task 1002620)
1884
+ 1.19.712 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1885
+ landed since 1.19.711 with no explicit bump. run 34745412403. (task 1002620)
1884
1886
  ---------------------------------------------------------------------------
1885
1887
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.711",
3
+ "version": "1.19.712",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.711",
9
+ "version": "1.19.712",
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.711",
3
+ "version": "1.19.712",
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",
@@ -44,10 +44,27 @@ const HEALTH_POLL_MS = 2_000;
44
44
  // GAME-ONLY entry (src/preview-server.js, ADR 0052), so only game-rendering paths
45
45
  // are previewable — GDS/internal-tooling diffs have nothing to look at there.
46
46
  // src/preview-server.js + server.js are the entrypoints that render the game.
47
- const PREVIEWABLE_PREFIXES = ['public/', 'src/world/', 'src/rooms/'];
48
- const PREVIEWABLE_FILES = new Set(['server.js', 'src/preview-server.js']);
49
- // Same surfaces as git pathspecs (no trailing slash) for scoped plumbing calls.
50
- const PREVIEWABLE_PATHSPECS = ['public', 'src/world', 'src/rooms', 'server.js', 'src/preview-server.js'];
47
+ // The GAME CLIENT directories the sandbox renders. These MOVED in the module
48
+ // carve (ADR 0083, BV1.R49 / task 1418): public/ -> modules/game/public/,
49
+ // src/world/ -> modules/game/world/, src/rooms/ -> modules/game/rooms/. The
50
+ // pre-carve spelling sat here matching NOTHING, and staging still reported
51
+ // success because the two entrypoints below kept syncing — so a builder staged,
52
+ // saw 'synced previewable surfaces', and got a sandbox serving whatever client
53
+ // was already on /workspace (task 1002062; found when a new /studio page 404'd).
54
+ const PREVIEWABLE_DIRS = ['modules/game/public', 'modules/game/world', 'modules/game/rooms'];
55
+ // The single directory the preview serves AT THE URL ROOT: modules/game/public/x
56
+ // is served as /x. pickVerifyTarget maps between the two, so it must be exactly
57
+ // one dir — hence its own constant rather than PREVIEWABLE_DIRS[0].
58
+ const PREVIEWABLE_STATIC_DIR = 'modules/game/public';
59
+ // The entrypoints that render the game. A sparse box (STARTER_SPARSE_PATHS) may
60
+ // legitimately lack these, which is why a miss here is not on its own an error.
61
+ const PREVIEWABLE_ENTRYPOINTS = ['server.js', 'src/preview-server.js'];
62
+ // DERIVED, all three of them. They used to be three hand-maintained copies of the
63
+ // same list, and the carve updated none of them; deriving removes the drift class
64
+ // rather than just this instance of it.
65
+ const PREVIEWABLE_PREFIXES = PREVIEWABLE_DIRS.map((d) => `${d}/`);
66
+ const PREVIEWABLE_FILES = new Set(PREVIEWABLE_ENTRYPOINTS);
67
+ const PREVIEWABLE_PATHSPECS = [...PREVIEWABLE_DIRS, ...PREVIEWABLE_ENTRYPOINTS];
51
68
 
52
69
  // On a box, box-game-preview.service has WorkingDirectory=/workspace and serves
53
70
  // /workspace/public — the MAIN checkout. A Claude session, though, runs in a git
@@ -316,6 +333,11 @@ function syncWorktreeToServed(deps = {}) {
316
333
  // then overlay the session branch's content.
317
334
  let synced = false;
318
335
  let lastErr = null;
336
+ // task 1002062: this collapsed to a single boolean, so a run that synced ONLY
337
+ // the entrypoints and missed every client directory reported plain success.
338
+ // Track them by name — the caller warns on a missed DIRECTORY, which is the
339
+ // shape a stale pathspec list takes.
340
+ const missed = [];
319
341
  for (const ps of PREVIEWABLE_PATHSPECS) {
320
342
  try {
321
343
  run('git', ['-C', servedRoot, 'checkout', '--', ps], { stdio: 'ignore' });
@@ -327,12 +349,16 @@ function syncWorktreeToServed(deps = {}) {
327
349
  synced = true;
328
350
  } catch (e) {
329
351
  lastErr = e; // pathspec absent in the branch (sparse/missing) — skip it
352
+ missed.push(ps);
330
353
  }
331
354
  }
332
355
  if (!synced) {
333
- return { ok: false, synced: false, reason: `git checkout ${branch} -- <previewable> in ${servedRoot} synced nothing${lastErr ? ` (${lastErr.message})` : ''}` };
356
+ return { ok: false, synced: false, missed, reason: `git checkout ${branch} -- <previewable> in ${servedRoot} synced nothing${lastErr ? ` (${lastErr.message})` : ''}` };
334
357
  }
335
- return { ok: true, synced: true, branch };
358
+ // A missed ENTRYPOINT is normal on a sparse box. A missed client DIRECTORY means
359
+ // the sandbox is about to serve a client this stage never touched.
360
+ const missedDirs = missed.filter((m) => PREVIEWABLE_DIRS.includes(m));
361
+ return { ok: true, synced: true, branch, missed, missedDirs };
336
362
  }
337
363
 
338
364
  // Pick the smallest cheap-to-compare changed previewable static file to verify,
@@ -343,21 +369,26 @@ function pickVerifyTarget(deps = {}) {
343
369
  const cwd = deps.cwd || process.cwd();
344
370
  const f = deps.fs || fs;
345
371
  try {
346
- const out = run('git', ['status', '--porcelain', '--', 'public'], { cwd, encoding: 'utf8' });
372
+ // task 1002062: this hardcoded 'public' three times, independently of the
373
+ // constants above, so it kept the pre-carve spelling even after they moved.
374
+ // Derived from PREVIEWABLE_STATIC_DIR now, so there is one place to change.
375
+ const staticDir = deps.staticDir || PREVIEWABLE_STATIC_DIR;
376
+ const prefix = `${staticDir}/`;
377
+ const out = run('git', ['status', '--porcelain', '--', staticDir], { cwd, encoding: 'utf8' });
347
378
  let chosen = out
348
379
  .split('\n')
349
380
  .map((line) => line.slice(3).replace(/^"|"$/g, '').trim())
350
- .find((rel) => rel && rel.startsWith('public/') && f.existsSync(path.join(cwd, rel)));
381
+ .find((rel) => rel && rel.startsWith(prefix) && f.existsSync(path.join(cwd, rel)));
351
382
  if (!chosen) {
352
383
  // committed-only change: fall back to the diff vs the frozen origin/main
353
- const diff = run('git', ['diff', '--name-only', 'origin/main...HEAD', '--', 'public'], { cwd, encoding: 'utf8' });
384
+ const diff = run('git', ['diff', '--name-only', 'origin/main...HEAD', '--', staticDir], { cwd, encoding: 'utf8' });
354
385
  chosen = diff
355
386
  .split('\n')
356
387
  .map((s) => s.trim())
357
- .find((p) => p.startsWith('public/') && f.existsSync(path.join(cwd, p)));
388
+ .find((p) => p.startsWith(prefix) && f.existsSync(path.join(cwd, p)));
358
389
  }
359
390
  if (!chosen) return null;
360
- return { relPath: chosen, urlPath: '/' + chosen.replace(/^public\//, '') };
391
+ return { relPath: chosen, urlPath: '/' + chosen.slice(prefix.length) };
361
392
  } catch {
362
393
  return null;
363
394
  }
@@ -374,7 +405,7 @@ async function verifyServedFresh(deps = {}) {
374
405
  const cwd = deps.cwd || process.cwd();
375
406
  const base = (deps.healthUrl || HEALTH_URL).replace(/\/$/, '');
376
407
  const target = pickVerifyTarget(deps);
377
- if (!target) return { ok: true, skipped: true, reason: 'no served static change to verify (e.g. only src/rooms changed)' };
408
+ if (!target) return { ok: true, skipped: true, reason: `no served static change to verify (e.g. only ${PREVIEWABLE_DIRS.filter((d) => d !== PREVIEWABLE_STATIC_DIR).join(' or ')} changed)` };
378
409
  let want;
379
410
  try {
380
411
  want = f.readFileSync(path.join(cwd, target.relPath), 'utf8');
@@ -455,6 +486,16 @@ async function stage({ taskId } = {}, deps = {}) {
455
486
  return { ok: false, hard: true, reason: `could not sync your worktree to the served checkout (${sync.reason}); the sandbox would show stale ${deps.servedRoot || BOX_SERVED_ROOT} code` };
456
487
  }
457
488
  if (sync.synced) console.log(` synced previewable surfaces from ${sync.branch} into ${deps.servedRoot || BOX_SERVED_ROOT}`);
489
+ // task 1002062 — the tell that went unsaid for 69 days. Staging "succeeded"
490
+ // while every game-client directory matched nothing, so the sandbox served a
491
+ // client the stage never touched and the only hint was a freshness-verify skip.
492
+ if (sync.missedDirs && sync.missedDirs.length) {
493
+ console.warn(
494
+ ` ⚠ these previewable directories matched NOTHING in ${sync.branch}: ${sync.missedDirs.join(', ')}`
495
+ + ' — the sandbox will serve whatever client is already on the served checkout.'
496
+ + ' If this repo carved or renamed them, PREVIEWABLE_DIRS in sandbox-stage.js is stale.'
497
+ );
498
+ }
458
499
  }
459
500
  try {
460
501
  ctx.restart(deps);
@@ -699,6 +740,9 @@ module.exports = {
699
740
  BOX_ENV_PATH,
700
741
  BOX_SERVED_ROOT,
701
742
  PREVIEWABLE_PATHSPECS,
743
+ PREVIEWABLE_DIRS,
744
+ PREVIEWABLE_ENTRYPOINTS,
745
+ PREVIEWABLE_STATIC_DIR,
702
746
  sessionCheckoutRoot,
703
747
  syncWorktreeToServed,
704
748
  pickVerifyTarget,
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.711'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.712'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -157,7 +157,6 @@ require.cache[authPath] = {
157
157
  id: authPath, filename: authPath, loaded: true,
158
158
  exports: {
159
159
  requireBuilder: (_req, _res, next) => next(),
160
- requireRank: () => (_req, _res, next) => next(),
161
160
  requirePermission: () => (_req, _res, next) => next(),
162
161
  // ADR 0215 D4: the sliver route beside the directory gates on RANK, so the
163
162
  // partial doorway must answer requireRank too or the router will not load.
@@ -57,7 +57,7 @@ t('previewUrl: reads box.env via injected fs; null when unreadable', () => {
57
57
 
58
58
  // ---------- previewable surfaces ----------
59
59
  t('isPreviewableFile: game surfaces yes, internal surfaces no', () => {
60
- for (const f of ['public/game/main.js', 'src/world/terrain.js', 'src/rooms/WorldRoom.js', 'server.js']) {
60
+ for (const f of ['modules/game/public/game/main.js', 'modules/game/world/terrain.js', 'modules/game/rooms/WorldRoom.js', 'server.js']) {
61
61
  assert.equal(sbx.isPreviewableFile(f), true, `${f} should be previewable`);
62
62
  }
63
63
  for (const f of ['scripts/gds/ship.js', 'modules/dev-box/routes/box.js', 'docs/adr/0046.md', 'modules/status-ui/public/index.html', 'modules/hall-ui/public/hall.js', 'migrations/075.sql', 'tests/sandbox_stage.mjs', 'server.js.bak']) {
@@ -66,7 +66,7 @@ t('isPreviewableFile: game surfaces yes, internal surfaces no', () => {
66
66
  });
67
67
 
68
68
  t('anyPreviewable: mixed diff counts as previewable; empty/none does not', () => {
69
- assert.equal(sbx.anyPreviewable(['docs/x.md', 'public/game/a.js']), true);
69
+ assert.equal(sbx.anyPreviewable(['docs/x.md', 'modules/game/public/game/a.js']), true);
70
70
  assert.equal(sbx.anyPreviewable(['docs/x.md', 'scripts/gds/y.js']), false);
71
71
  assert.equal(sbx.anyPreviewable([]), false);
72
72
  assert.equal(sbx.anyPreviewable(null), false);
@@ -162,9 +162,9 @@ function git(...args) { execFileSync('git', args, { cwd: tmp, encoding: 'utf8' }
162
162
  git('init', '-q');
163
163
  git('config', 'user.email', 'test@example.com');
164
164
  git('config', 'user.name', 'test');
165
- fs.mkdirSync(path.join(tmp, 'public'));
165
+ fs.mkdirSync(path.join(tmp, 'modules', 'game', 'public'), { recursive: true });
166
166
  fs.mkdirSync(path.join(tmp, 'docs'));
167
- fs.writeFileSync(path.join(tmp, 'public', 'game.js'), 'one\n');
167
+ fs.writeFileSync(path.join(tmp, 'modules', 'game', 'public', 'game.js'), 'one\n');
168
168
  fs.writeFileSync(path.join(tmp, 'server.js'), 'srv\n');
169
169
  fs.writeFileSync(path.join(tmp, 'docs', 'notes.md'), 'doc\n');
170
170
  git('add', '.');
@@ -181,7 +181,7 @@ t('computeTreeStamp: content-stable, scoped to game surfaces', () => {
181
181
  assert.equal(sbx.computeTreeStamp({ cwd: tmp }), s1, 'docs edit → stamp unchanged');
182
182
 
183
183
  // A game-surface edit produces a new stamp...
184
- fs.writeFileSync(path.join(tmp, 'public', 'game.js'), 'two\n');
184
+ fs.writeFileSync(path.join(tmp, 'modules', 'game', 'public', 'game.js'), 'two\n');
185
185
  const s3 = sbx.computeTreeStamp({ cwd: tmp });
186
186
  assert.notEqual(s3, s1, 'game edit → new stamp');
187
187
 
@@ -192,10 +192,10 @@ t('computeTreeStamp: content-stable, scoped to game surfaces', () => {
192
192
  assert.equal(sbx.computeTreeStamp({ cwd: tmp }), s3, 'commit of same content → stamp unchanged');
193
193
 
194
194
  // An untracked game file counts.
195
- fs.writeFileSync(path.join(tmp, 'public', 'new-tile.js'), 'untracked\n');
195
+ fs.writeFileSync(path.join(tmp, 'modules', 'game', 'public', 'new-tile.js'), 'untracked\n');
196
196
  const s4 = sbx.computeTreeStamp({ cwd: tmp });
197
197
  assert.notEqual(s4, s3, 'untracked game file → new stamp');
198
- fs.rmSync(path.join(tmp, 'public', 'new-tile.js'));
198
+ fs.rmSync(path.join(tmp, 'modules', 'game', 'public', 'new-tile.js'));
199
199
  assert.equal(sbx.computeTreeStamp({ cwd: tmp }), s3, 'removing it restores the stamp');
200
200
 
201
201
  assert.equal(sbx.computeTreeStamp({ cwd: os.tmpdir() }), null, 'not a repo → null');
@@ -265,7 +265,7 @@ await ta('runShipGate: no sandbox available (not a box, no local game entry) →
265
265
  // is present — a partial clone with no game. (A full local clone WITH the game
266
266
  // is covered by the LOCAL tests below, where it DOES stage.)
267
267
  const r = await sbx.runShipGate(
268
- { taskId: 1, files: ['public/game/a.js'], skipStage: false, tty: true },
268
+ { taskId: 1, files: ['modules/game/public/game/a.js'], skipStage: false, tty: true },
269
269
  { fs: { existsSync: () => false } }
270
270
  );
271
271
  assert.equal(r.ok, true);
@@ -281,7 +281,7 @@ await ta('runShipGate: box + non-game diff → ok, no staging', async () => {
281
281
  await ta('runShipGate: box + game diff + TTY, reviewer says yes → stages then ok, records tty sign-off', async () => {
282
282
  const mp = path.join(markerDir, 'm2.json');
283
283
  const deps = boxDeps(mp, { promptYesNo: async () => true });
284
- const r = await sbx.runShipGate({ taskId: 927, files: ['src/world/terrain.js'], skipStage: false, tty: true }, deps);
284
+ const r = await sbx.runShipGate({ taskId: 927, files: ['modules/game/world/terrain.js'], skipStage: false, tty: true }, deps);
285
285
  assert.equal(r.ok, true);
286
286
  const m = JSON.parse(fs.readFileSync(mp, 'utf8'));
287
287
  assert.equal(m.url, 'https://sandbox-test.example.com');
@@ -295,7 +295,7 @@ await ta('runShipGate: box + game diff + TTY, reviewer says yes → stages then
295
295
  await ta('runShipGate: reviewer says NO → aborts with claim-preserving reason', async () => {
296
296
  const mp = path.join(markerDir, 'm3.json');
297
297
  const deps = boxDeps(mp, { promptYesNo: async () => false });
298
- const r = await sbx.runShipGate({ taskId: 927, files: ['public/game/a.js'], skipStage: false, tty: true }, deps);
298
+ const r = await sbx.runShipGate({ taskId: 927, files: ['modules/game/public/game/a.js'], skipStage: false, tty: true }, deps);
299
299
  assert.equal(r.ok, false);
300
300
  assert.match(r.reason, /sandbox review declined/);
301
301
  });
@@ -321,14 +321,14 @@ await ta('runShipGate: stale marker forces a re-stage (stamp mismatch)', async (
321
321
  await ta('runShipGate: non-TTY → stages then ABORTS with the review URL (no prompt, no hang)', async () => {
322
322
  const mp = path.join(markerDir, 'm6.json');
323
323
  const deps = boxDeps(mp, { promptYesNo: async () => { throw new Error('must not prompt'); } });
324
- const r = await sbx.runShipGate({ taskId: 927, files: ['public/game/a.js'], skipStage: false, tty: false }, deps);
324
+ const r = await sbx.runShipGate({ taskId: 927, files: ['modules/game/public/game/a.js'], skipStage: false, tty: false }, deps);
325
325
  assert.equal(r.ok, false, 'unreviewed game work must not ship non-interactively');
326
326
  assert.equal(r.review, true, 'abort is flagged as a review pause');
327
327
  assert.match(r.reason, /staged at https:\/\/sandbox-test\.example\.com/i);
328
328
  assert.ok(fs.existsSync(mp), 'work is staged so the human can review at the URL');
329
329
  // ...and after the (notional) review, a bare re-run passes on the fresh marker
330
330
  // but records NO sign-off (no --approved) — back-compat / failure-open.
331
- const r2 = await sbx.runShipGate({ taskId: 927, files: ['public/game/a.js'], skipStage: false, tty: false }, deps);
331
+ const r2 = await sbx.runShipGate({ taskId: 927, files: ['modules/game/public/game/a.js'], skipStage: false, tty: false }, deps);
332
332
  assert.equal(r2.ok, true, 're-run after staging passes (marker fresh)');
333
333
  assert.equal(r2.sandboxReview, undefined, 'bare re-run records no sign-off');
334
334
  });
@@ -336,16 +336,16 @@ await ta('runShipGate: non-TTY → stages then ABORTS with the review URL (no pr
336
336
  await ta('runShipGate: uncommitted game edit counts as previewable even when committed diff does not', async () => {
337
337
  const mp = path.join(markerDir, 'm9.json');
338
338
  const deps = boxDeps(mp, { promptYesNo: async () => true });
339
- fs.writeFileSync(path.join(tmp, 'public', 'game.js'), 'dirty working tree\n');
339
+ fs.writeFileSync(path.join(tmp, 'modules', 'game', 'public', 'game.js'), 'dirty working tree\n');
340
340
  const r = await sbx.runShipGate({ taskId: 927, files: ['docs/x.md'], skipStage: false, tty: true }, deps);
341
341
  assert.equal(r.ok, true);
342
342
  assert.ok(fs.existsSync(mp), 'gate staged the dirty game surface');
343
- git('checkout', '--', 'public/game.js');
343
+ git('checkout', '--', 'modules/game/public/game.js');
344
344
  });
345
345
 
346
346
  await ta('runShipGate: --skip-stage → ok, untouched', async () => {
347
347
  const mp = path.join(markerDir, 'm7.json');
348
- const r = await sbx.runShipGate({ taskId: 927, files: ['public/game/a.js'], skipStage: true, tty: true }, boxDeps(mp));
348
+ const r = await sbx.runShipGate({ taskId: 927, files: ['modules/game/public/game/a.js'], skipStage: true, tty: true }, boxDeps(mp));
349
349
  assert.equal(r.ok, true);
350
350
  assert.equal(fs.existsSync(mp), false);
351
351
  });
@@ -357,7 +357,7 @@ await ta('runShipGate: --skip-stage → ok, untouched', async () => {
357
357
  await ta('1002607 runShipGate: staging failure (health never green) ABORTS — review gate cannot silently turn itself off', async () => {
358
358
  const mp = path.join(markerDir, 'm8.json');
359
359
  const deps = boxDeps(mp, { fetchImpl: async () => ({ status: 502 }), timeoutMs: 10 });
360
- const r = await sbx.runShipGate({ taskId: 927, files: ['public/game/a.js'], skipStage: false, tty: true }, deps);
360
+ const r = await sbx.runShipGate({ taskId: 927, files: ['modules/game/public/game/a.js'], skipStage: false, tty: true }, deps);
361
361
  assert.equal(r.ok, false, 'a failed stage must not ship as if the review happened');
362
362
  assert.equal(r.review, undefined, 'a failure to present is NOT a calm review pause');
363
363
  assert.match(r.reason, /DID NOT run/, 'says plainly the review step did not run');
@@ -384,7 +384,7 @@ await ta('runShipGate: --approved cannot pre-approve an UNstaged change (still s
384
384
  const deps = boxDeps(mp, { promptYesNo: async () => { throw new Error('must not prompt'); } });
385
385
  // No marker → not staged-fresh → --approved is ignored; the gate still stages
386
386
  // + aborts for review. You cannot sign off on something never shown.
387
- const r = await sbx.runShipGate({ taskId: 1048, files: ['public/game/a.js'], skipStage: false, tty: false, approved: true }, deps);
387
+ const r = await sbx.runShipGate({ taskId: 1048, files: ['modules/game/public/game/a.js'], skipStage: false, tty: false, approved: true }, deps);
388
388
  assert.equal(r.ok, false, '--approved must not bypass the first stage + review');
389
389
  assert.equal(r.review, true);
390
390
  assert.equal(r.sandboxReview, undefined, 'nothing recorded — nothing was staged to approve');
@@ -402,13 +402,13 @@ await ta('syncWorktreeToServed: brings the branch content into the served checko
402
402
  const g = (...a) => execFileSync('git', a, { cwd: served, encoding: 'utf8' });
403
403
  g('init', '-q');
404
404
  g('config', 'user.email', 't@e.com'); g('config', 'user.name', 't');
405
- fs.mkdirSync(path.join(served, 'public'));
406
- fs.writeFileSync(path.join(served, 'public', 'game.js'), 'MAIN content\n');
405
+ fs.mkdirSync(path.join(served, 'modules', 'game', 'public'), { recursive: true });
406
+ fs.writeFileSync(path.join(served, 'modules', 'game', 'public', 'game.js'), 'MAIN content\n');
407
407
  g('add', '.'); g('commit', '-qm', 'main');
408
408
  // a linked worktree on a feature branch with different game content
409
409
  const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-wt-')) + '/co';
410
410
  g('worktree', 'add', '-q', '-b', 'claude/feat', wt);
411
- fs.writeFileSync(path.join(wt, 'public', 'game.js'), 'BRANCH content\n');
411
+ fs.writeFileSync(path.join(wt, 'modules', 'game', 'public', 'game.js'), 'BRANCH content\n');
412
412
  execFileSync('git', ['add', '.'], { cwd: wt });
413
413
  execFileSync('git', ['commit', '-qm', 'branch edit'], { cwd: wt });
414
414
 
@@ -419,7 +419,7 @@ await ta('syncWorktreeToServed: brings the branch content into the served checko
419
419
  // The claim is WHICH content landed, not how its lines end: git checks the fixture
420
420
  // out under the machine's own autocrlf, so on Windows the file comes back CRLF and a
421
421
  // byte-exact compare failed for a reason unrelated to the sync (task 1003529).
422
- assert.ok(sameContent(fs.readFileSync(path.join(served, 'public', 'game.js'), 'utf8'), 'BRANCH content\n'),
422
+ assert.ok(sameContent(fs.readFileSync(path.join(served, 'modules', 'game', 'public', 'game.js'), 'utf8'), 'BRANCH content\n'),
423
423
  'served /workspace now shows the SESSION branch content, not stale main');
424
424
 
425
425
  g('worktree', 'remove', '--force', wt);
@@ -469,26 +469,26 @@ t('syncWorktreeToServed: detached HEAD / unresolvable branch → not ok', () =>
469
469
 
470
470
  await ta('verifyServedFresh: served bytes match worktree → ok/verified', async () => {
471
471
  // dirty a tracked game file so pickVerifyTarget selects it
472
- fs.writeFileSync(path.join(tmp, 'public', 'game.js'), 'served-match\n');
472
+ fs.writeFileSync(path.join(tmp, 'modules', 'game', 'public', 'game.js'), 'served-match\n');
473
473
  const r = await sbx.verifyServedFresh({
474
474
  cwd: tmp,
475
475
  healthUrl: 'http://127.0.0.1:3000/',
476
476
  fetchImpl: async (url) => { assert.match(url, /\/game\.js$/); return { status: 200, text: async () => 'served-match\n' }; },
477
477
  });
478
478
  assert.equal(r.ok, true);
479
- assert.equal(r.verified, 'public/game.js');
480
- execFileSync('git', ['checkout', '--', 'public/game.js'], { cwd: tmp });
479
+ assert.equal(r.verified, 'modules/game/public/game.js');
480
+ execFileSync('git', ['checkout', '--', 'modules/game/public/game.js'], { cwd: tmp });
481
481
  });
482
482
 
483
483
  await ta('verifyServedFresh: served bytes DIFFER → not ok (serving stale code)', async () => {
484
- fs.writeFileSync(path.join(tmp, 'public', 'game.js'), 'worktree-new\n');
484
+ fs.writeFileSync(path.join(tmp, 'modules', 'game', 'public', 'game.js'), 'worktree-new\n');
485
485
  const r = await sbx.verifyServedFresh({
486
486
  cwd: tmp,
487
487
  fetchImpl: async () => ({ status: 200, text: async () => 'stale-old\n' }),
488
488
  });
489
489
  assert.equal(r.ok, false);
490
490
  assert.match(r.reason, /serving stale code/);
491
- execFileSync('git', ['checkout', '--', 'public/game.js'], { cwd: tmp });
491
+ execFileSync('git', ['checkout', '--', 'modules/game/public/game.js'], { cwd: tmp });
492
492
  });
493
493
 
494
494
  await ta('verifyServedFresh: no served static change → skipped (ok)', async () => {
@@ -506,7 +506,7 @@ await ta('stage(box): a HARD sync failure → {ok:false,hard:true}, no marker; r
506
506
  assert.equal(staged.hard, true);
507
507
  assert.equal(fs.existsSync(mp), false, 'no marker on a hard sync failure');
508
508
  // and the gate turns a hard failure into a claim-preserving abort
509
- const r = await sbx.runShipGate({ taskId: 1068, files: ['public/game/a.js'], skipStage: false, tty: true }, deps);
509
+ const r = await sbx.runShipGate({ taskId: 1068, files: ['modules/game/public/game/a.js'], skipStage: false, tty: true }, deps);
510
510
  assert.equal(r.ok, false);
511
511
  assert.equal(r.review, true);
512
512
  assert.match(r.reason, /could not be trusted/);
@@ -526,7 +526,7 @@ await ta('1002607 runShipGate(box): a SOFT failure (preview never healthy) now f
526
526
  // sync ok (injected), but health never green → the ship must NOT continue
527
527
  // as if the review ran (that was the silent-disable defect).
528
528
  const deps = boxDeps(mp, { fetchImpl: async () => ({ status: 502 }), timeoutMs: 10 });
529
- const r = await sbx.runShipGate({ taskId: 1068, files: ['public/game/a.js'], skipStage: false, tty: true }, deps);
529
+ const r = await sbx.runShipGate({ taskId: 1068, files: ['modules/game/public/game/a.js'], skipStage: false, tty: true }, deps);
530
530
  assert.equal(r.ok, false, 'the soft failure aborts instead of silently skipping the review');
531
531
  assert.match(r.reason, /sandbox staging failed/);
532
532
  });
@@ -610,7 +610,7 @@ function localDeps(markerFile, overrides = {}) {
610
610
 
611
611
  await ta('runShipGate(local): game diff + non-TTY → stages then ABORTS with the localhost URL', async () => {
612
612
  const mp = path.join(markerDir, 'lm1.json');
613
- const r = await sbx.runShipGate({ taskId: 1056, files: ['public/game/a.js'], skipStage: false, tty: false }, localDeps(mp));
613
+ const r = await sbx.runShipGate({ taskId: 1056, files: ['modules/game/public/game/a.js'], skipStage: false, tty: false }, localDeps(mp));
614
614
  assert.equal(r.ok, false, 'unreviewed game work must not ship non-interactively on a laptop');
615
615
  assert.equal(r.review, true);
616
616
  assert.match(r.reason, /staged at http:\/\/localhost:3100/i);
@@ -619,7 +619,7 @@ await ta('runShipGate(local): game diff + non-TTY → stages then ABORTS with th
619
619
 
620
620
  await ta('runShipGate(local): TTY yes → records a tty sign-off with the localhost URL + kind=local marker', async () => {
621
621
  const mp = path.join(markerDir, 'lm2.json');
622
- const r = await sbx.runShipGate({ taskId: 1056, files: ['src/world/terrain.js'], skipStage: false, tty: true }, localDeps(mp, { promptYesNo: async () => true }));
622
+ const r = await sbx.runShipGate({ taskId: 1056, files: ['modules/game/world/terrain.js'], skipStage: false, tty: true }, localDeps(mp, { promptYesNo: async () => true }));
623
623
  assert.equal(r.ok, true);
624
624
  assert.equal(r.sandboxReview.method, 'tty');
625
625
  assert.equal(r.sandboxReview.url, 'http://localhost:3100');
@@ -648,5 +648,86 @@ await ta('runShipGate(local): non-game diff (clean tree) → skip, no staging',
648
648
 
649
649
  fs.rmSync(tmp, { recursive: true, force: true });
650
650
 
651
+ // ---------- 1002062: the surface list must describe a REAL tree ----------
652
+ //
653
+ // WHY THESE EXIST. Every case above feeds sandbox-stage its own fixture paths,
654
+ // so the previewable list matched NOTHING in the real repo for 69 days and all
655
+ // 47 tests stayed green. A suite that supplies the paths it tests cannot notice
656
+ // that the paths are wrong. These four look at the real thing instead.
657
+
658
+ t('1002062 the three surface spellings are DERIVED, so they cannot drift apart', () => {
659
+ // They used to be three hand-maintained copies. The module carve updated none
660
+ // of them, and nothing compared them. Derivation is the fix; this pins it.
661
+ assert.deepEqual(
662
+ sbx.PREVIEWABLE_PATHSPECS,
663
+ [...sbx.PREVIEWABLE_DIRS, ...sbx.PREVIEWABLE_ENTRYPOINTS],
664
+ "PATHSPECS must be derived from DIRS + ENTRYPOINTS, not hand-listed"
665
+ );
666
+ for (const d of sbx.PREVIEWABLE_DIRS) {
667
+ assert.equal(sbx.isPreviewableFile(`${d}/some/file.js`), true, `${d} must match via the derived prefixes`);
668
+ }
669
+ for (const e of sbx.PREVIEWABLE_ENTRYPOINTS) {
670
+ assert.equal(sbx.isPreviewableFile(e), true, `${e} must match via the derived file set`);
671
+ }
672
+ });
673
+
674
+ t('1002062 no PRE-CARVE relic survives in the surface list', () => {
675
+ // The exact spellings that were stale: the carve (ADR 0083 / task 1418) moved
676
+ // them under modules/game/ and this list kept the old ones.
677
+ for (const relic of ['public', 'src/world', 'src/rooms']) {
678
+ assert.ok(
679
+ !sbx.PREVIEWABLE_PATHSPECS.includes(relic),
680
+ `${relic} is the pre-carve spelling and matches nothing — it must not be back`
681
+ );
682
+ }
683
+ assert.ok(sbx.PREVIEWABLE_STATIC_DIR.startsWith('modules/'), 'the served static dir is module-carved');
684
+ });
685
+
686
+ t('1002062 a client directory that matched NOTHING is reported, not swallowed', () => {
687
+ // THE ACTUAL BUG. The sync collapsed to one boolean, so a run that synced only
688
+ // the entrypoints and missed every client dir reported plain success — the
689
+ // reporter saw "synced previewable surfaces" and got a stale sandbox.
690
+ const served = fs.mkdtempSync(path.join(os.tmpdir(), "sbx-missed-"));
691
+ const session = fs.mkdtempSync(path.join(os.tmpdir(), "sbx-sess-"));
692
+ const calls = [];
693
+ const execFileSyncStub = (bin, args) => {
694
+ const a = args.join(" ");
695
+ if (a.includes("rev-parse")) return "feature-branch\n";
696
+ // Entrypoints resolve; every client directory is absent from the branch.
697
+ const ps = args[args.length - 1];
698
+ if (sbx.PREVIEWABLE_DIRS.includes(ps) && a.includes("feature-branch")) {
699
+ throw new Error(`pathspec ${ps} did not match any file(s) known to git`);
700
+ }
701
+ calls.push(a);
702
+ return "";
703
+ };
704
+ const r = sbx.syncWorktreeToServed({
705
+ execFileSync: execFileSyncStub,
706
+ servedRoot: served,
707
+ sessionRoot: session,
708
+ cwd: session,
709
+ });
710
+ assert.equal(r.ok, true, "the entrypoints synced, so the sync itself is not a hard failure");
711
+ assert.deepEqual(
712
+ r.missedDirs, sbx.PREVIEWABLE_DIRS,
713
+ "every client directory missed, and the caller must be told which"
714
+ );
715
+ });
716
+
717
+ t('1002062 pickVerifyTarget maps the CARVED static dir onto the served URL', () => {
718
+ // modules/game/public/game.js is served at /game.js. The old code stripped a
719
+ // hardcoded "public/" prefix, which post-carve would have left the whole
720
+ // modules/game/ path in the URL.
721
+ const rel = `${sbx.PREVIEWABLE_STATIC_DIR}/game.js`;
722
+ const target = sbx.pickVerifyTarget({
723
+ cwd: "/nowhere",
724
+ fs: { existsSync: () => true },
725
+ execFileSync: (bin, args) => (args.includes("--porcelain") ? ` M ${rel}\n` : ""),
726
+ });
727
+ assert.ok(target, "a changed file under the static dir must be pickable");
728
+ assert.equal(target.relPath, rel);
729
+ assert.equal(target.urlPath, "/game.js", "the carved prefix is stripped, not just \"public/\"");
730
+ });
731
+
651
732
  console.log(`\nsandbox_stage: ${passed} passed, ${failed} failed`);
652
733
  process.exit(failed ? 1 : 0);