@bongos/core 1.19.631 → 1.19.633

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.
@@ -1711,5 +1711,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1711
1711
  landed since 1.19.629 with no explicit bump. run 34415169250. (task 1002620)
1712
1712
  1.19.631 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1713
1713
  landed since 1.19.630 with no explicit bump. run 34415537907. (task 1002620)
1714
+ 1.19.632 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1715
+ landed since 1.19.631 with no explicit bump. run 34417373737. (task 1002620)
1716
+ 1.19.633 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1717
+ landed since 1.19.632 with no explicit bump. run 34417769106. (task 1002620)
1714
1718
  ---------------------------------------------------------------------------
1715
1719
  ```
@@ -211,6 +211,11 @@
211
211
  // not a local: renderNav takes (builder, onboarding) and is called from
212
212
  // five sites, so there is no `me` in scope here.
213
213
  social: (ctx.lastMeState && ctx.lastMeState.social) || null,
214
+ // task 1003744: the resolved permission map, for the `perm:` nav gates. Read
215
+ // off ctx.lastMeState for the same reason `social` is, and passed here for
216
+ // the same reason: without it the index — the hall's busiest page — is the
217
+ // one page a permission-gated nav item would never appear on.
218
+ permissions: (ctx.lastMeState && ctx.lastMeState.permissions) || null,
214
219
  builder,
215
220
  });
216
221
 
@@ -120,6 +120,14 @@
120
120
  // `government` MODULE key, and the two are unrelated (ADR 0174).
121
121
  // Mirrors the server's `page.view.government` floor, which that ADR
122
122
  // lowered archon→metic. Cosmetic only — requireGovernmentPage is the wall.
123
+ // perm:<atom> — the viewer HOLDS that permission, per the resolved map `/me`
124
+ // returns in `permissions` (task 1003744). The one gate key that is
125
+ // not a rank: an item whose page is gated on an ATOM declares the
126
+ // SAME atom here instead of borrowing the nearest rank floor, so the
127
+ // two cannot drift. The atom must be listed in NAV_PERMISSION_ATOMS
128
+ // (src/bongos/routes/me.js) or `/me` never resolves it and the item
129
+ // stays hidden — deliberately, since an unresolvable gate is a gate
130
+ // that cannot be vouched for.
123
131
  // `view` marks an index view (hash-routed); `module` gates on window.__MODULES__.
124
132
  // task 2175: grouped by AUDIENCE of the data, so the sidebar reads in coherent
125
133
  // runs instead of one long list — the project (unlabeled), You, Community,
@@ -140,6 +148,27 @@
140
148
  // active-nav detection (#nav-work), the 'work' gate case, and the server
141
149
  // page-gate all keep working — only the visible label and order change.
142
150
  { id: 'work', label: 'Tasks', icon: 'work', href: '/work', gate: 'signed' },
151
+ // task 1003744 (goal 1000111): the Board Room lives HERE, in the project's
152
+ // own group, and gates on the atom its page gates on.
153
+ //
154
+ // WHY IT LEFT GOVERNMENT. ADR 0266 carved the room out of /government, so
155
+ // the item first landed in the Government group and read that group's metic
156
+ // floor — the closest rank the shell could then express. Its real wall is
157
+ // `board.vote.cast` (auth.js requireBoardVotePage), which floors at XENOS on
158
+ // purpose so that WIDENING the board works (modules/government/board.js). The
159
+ // moment an instance seats a sub-Metic member — the config change ADR 0175 §6
160
+ // exists to make — that member could reach the room by URL and by the
161
+ // board_votes need's deep link, with no sidebar entry.
162
+ //
163
+ // Fixing the gate alone would have put a wider item under a metic group head,
164
+ // so a newcomer board member would see a group labelled "Government" holding
165
+ // one link — more authority than they hold. The owner's call (2026-09-09) was
166
+ // to move it instead: a sitting is work the project does, beside Goals and
167
+ // Tasks, not a monitoring surface. The Government group's audience and floor
168
+ // are unchanged.
169
+ //
170
+ // Cosmetic only, as every gate here is — the page gate is the wall (ADR 0016).
171
+ { id: 'board-room', label: 'Board Room', icon: 'admin', href: '/board-room', gate: 'perm:board.vote.cast' },
143
172
  // task 1003113 (R02 of goal 1000074): the artist's queue over the words the
144
173
  // product shows people. gate 'signed' and NOT a craft gate — filing a flag
145
174
  // is open to every role on purpose (the engineer who wrote the toast and
@@ -246,22 +275,11 @@
246
275
  // Archon-only, so a group header reading "Archon" over items a Metic can open
247
276
  // would misdescribe the ladder to the builders most likely to be learning it.
248
277
  { group: 'Government', gate: 'government', items: [
249
- // FIRST in the group because it is the one page here you ACT on — the
250
- // Community group's ordering rule, applied (a sitting waits on a decision;
251
- // the rest of this group is monitoring). Carved out of the /government tab
252
- // strip by ADR 0266, which is also why it needs its own item at all: while
253
- // it was a tab, the group's only door to it was labelled "Permissions".
254
- //
255
- // THE GATE HERE IS COSMETIC AND DELIBERATELY CONSERVATIVE. The page's real
256
- // wall is `board.vote.cast` (auth.js requireBoardVotePage), which floors at
257
- // xenos so a widened board can reach the room. This nav gate cannot say
258
- // that: gateAllows() speaks rank, not permission. It is set to the group's
259
- // floor because today's constitution seats `rank:archon`, so no sub-Metic
260
- // member exists to hide the link from. An instance that widens membership
261
- // below Metic gets a room it can reach by URL and by the board_votes need's
262
- // deep link, but no sidebar entry — teaching the shell a permission-aware
263
- // gate is filed rather than guessed at here.
264
- { id: 'board-room', label: 'Board Room', icon: 'admin', href: '/board-room', gate: 'government' },
278
+ // The Board Room was FIRST here from the ADR 0266 carve until task 1003744
279
+ // moved it into the project group its wall is an ATOM (`board.vote.cast`,
280
+ // xenos floor) rather than this group's rank, and a group whose audience is
281
+ // Metic+ oversight could not honestly hold it. Everything below IS a rank
282
+ // surface, so this group's gate and audience are unchanged.
265
283
  { id: 'watch', label: 'Watch', icon: 'admin', href: '/watch', gate: 'government' },
266
284
  { id: 'harbor', label: 'Harbor', icon: 'admin', href: '/harbor', gate: 'government', module: 'dev-box' },
267
285
  { id: 'gate', label: 'Gate', icon: 'admin', href: '/gate', gate: 'government' },
@@ -536,11 +554,25 @@
536
554
 
537
555
  // ---------- gated visibility ----------
538
556
 
539
- var access = { authed: null, rank: null, graduated: false, showWork: false };
557
+ // `permissions` is the resolved map `/me` returns (task 1003744) { '<atom>':
558
+ // boolean } over the atoms a nav item may gate on — or null when the server could
559
+ // not establish authority. null is NOT "holds nothing that matters": it is
560
+ // unreadable, and unreadable hides the link, the same call gateAllows()'s
561
+ // `default: false` and the social mode's OFF-is-the-fallback rule make.
562
+ var access = { authed: null, rank: null, graduated: false, showWork: false, permissions: null };
540
563
 
541
564
  function gateAllows(gate) {
542
565
  var authed = access.authed === true;
543
566
  var member = authed && access.rank !== 'xenos';
567
+ // task 1003744: the one gate key that is not a rank. `perm:<atom>` asks whether
568
+ // the viewer HOLDS that permission, reading the answer the SERVER resolved —
569
+ // never re-derived here from rank or anything else, the social-mode precedent:
570
+ // a page that decided for itself could disagree with the rest of the hall.
571
+ if (gate && gate.indexOf('perm:') === 0) {
572
+ var perms = access.permissions;
573
+ if (!authed || !perms) return false;
574
+ return perms[gate.slice(5)] === true;
575
+ }
544
576
  switch (gate) {
545
577
  case 'always': return true;
546
578
  case 'signed': return authed;
@@ -604,6 +636,13 @@
604
636
  if ('rank' in state) access.rank = state.rank ? String(state.rank).toLowerCase() : null;
605
637
  if ('graduated' in state) access.graduated = !!state.graduated;
606
638
  if ('showWork' in state) access.showWork = !!state.showWork;
639
+ // task 1003744: stored as given — an object or null. Not normalized into `{}`,
640
+ // because "the server said you hold none of them" and "the server could not
641
+ // say" are different facts and only the first is a real answer; both hide the
642
+ // link today, but a surface that later wants to SAY which one it is can.
643
+ if ('permissions' in state) {
644
+ access.permissions = (state.permissions && typeof state.permissions === 'object') ? state.permissions : null;
645
+ }
607
646
  if ('social' in state) applySocial(state.social);
608
647
  if (state.builder) { renderUser(state.builder); applyCraftEmphasis(state.builder); }
609
648
  }
@@ -821,6 +860,8 @@
821
860
  // task 1003178 (C08): the mode rides the identity every page already
822
861
  // fetches, so no surface needs a route or a second call to know.
823
862
  social: me.social || null,
863
+ // task 1003744: the resolved permission map, for the `perm:` nav gates.
864
+ permissions: me.permissions || null,
824
865
  builder: me.builder,
825
866
  });
826
867
  }).catch(function () { /* cosmetic only */ });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.631",
3
+ "version": "1.19.633",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.631",
9
+ "version": "1.19.633",
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.631",
3
+ "version": "1.19.633",
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",
@@ -339,6 +339,53 @@ const MODULE_GLOBS = {
339
339
  'scripts/gds/redteam-patrol.js', 'scripts/gds/audit-gate.js',
340
340
  'scripts/gds/fitness-ratchets.js', 'scripts/gds/control-manifest.js',
341
341
  ],
342
+ // --- the METHODOLOGY surfaces: the instruction core an agent session actually
343
+ // reads — the always-loaded root CLAUDE.md, the docs library it indexes, the
344
+ // slash-command skills, and the two scripts/gds files that compose a session's
345
+ // opening context (start.js and the discipline→mode table it routes on).
346
+ // Added by task 1002989 (goal 1000095, working area 6) as PREP for the
347
+ // kernel↔pack split (task 1002990): that work rewrites CLAUDE.md, authors the
348
+ // Engineer/Artist/Ideator packs, and deletes .claude/skills/{dev,ideate,paint}/
349
+ // — paths NO key resolved, so the goal could not wall its own work (the
350
+ // 1002690 / 1002736 / 1003124 precedent: a missing key is an empty wall, and
351
+ // goal-scope-check then denies every member-authored task).
352
+ // `protected` DERIVES true — the root CLAUDE.md, docs/canonical-permissions.md
353
+ // and the two trust ADRs sit in the ADR 0043 protected-surfaces registry — so a
354
+ // goal scoped here is Archon-only (BV1.R57). Correct: this is the text that
355
+ // frames every agent session.
356
+ // BOUNDARIES.
357
+ // • 'docs/' is deliberately the WHOLE subtree. The ADRs, recipes, session logs,
358
+ // audits and design notes are one library; splitting it per module would rot
359
+ // on the next doc, and the glob format has no wildcards to carve with. The
360
+ // exact docs/ paths other keys enumerate (kernel's module-api-changelog,
361
+ // copy-desk's two copy artifacts, ui-design's two loop recipes) stay listed
362
+ // there for task affinity — overlap is legal (touchesOverlap is prefix-aware
363
+ // both directions) and it keeps those walls standing on their own.
364
+ // KNOWN CONSEQUENCE: docs/ was previously owned by nobody, which the ship-time
365
+ // module-drift advisory (scripts/gds/ship-flow.js, BV1.R53) read as
366
+ // "cross-cutting, allowed". A module_key-labelled task that also writes a
367
+ // session log / ADR / regenerated doc now trips that WARNING (advisory only —
368
+ // it refuses solely under --enforce-module). Filed as an idea to teach the
369
+ // drift check about cross-cutting keys rather than shrink this glob.
370
+ // • '.claude/skills/' is the whole dir, on the precedent the map already sets
371
+ // for autonomy ('.claude/scheduled-tasks/') and agents ('.claude/agents/'):
372
+ // a definition surface travels with the system that composes it. The
373
+ // devsecops note — skills stay with whichever module ships the skill — still
374
+ // holds for a module that enumerates its own (ui-design lists three); this
375
+ // key is the floor under the ~50 that no module claims.
376
+ // • '.claude/rules/' is pre-carved territory for task 1003687 (move the
377
+ // CLAUDE.md content charter to a paths-scoped rule file) — the carve precedes
378
+ // the feature, ADR 0086 §4, on the devsecops '.npmrc' precedent.
379
+ // NOT here: scripts/gds/fitness.js. Task 1002989's brief named it as uncovered,
380
+ // but that brief predates task 1003124, which gave devsecops the fitness/entropy
381
+ // tooling. One owner per enforcement tool — the CLAUDE.md budget check is
382
+ // enforcement, not methodology prose. ---
383
+ methodology: [
384
+ 'CLAUDE.md',
385
+ 'docs/',
386
+ '.claude/skills/', '.claude/rules/',
387
+ 'scripts/gds/start.js', 'scripts/gds/discipline-modes.json',
388
+ ],
342
389
  };
343
390
 
344
391
  // A glob set is protected iff it overlaps the imported PROTECTED_GLOBS — the one
@@ -40,6 +40,7 @@ const { branding } = require('../../branding'); // task 2011: instance model-all
40
40
  // direct require of a module file.
41
41
  const { pool } = require('../pool');
42
42
  const seams = require('../../module-seams');
43
+ const log = require('../logger').logger.child({ src: 'me' });
43
44
  // The lifecycle (tasks · claims · ship) carved to modules/lifecycle/ (BV1.R86 /
44
45
  // ADR 0093 §3): the per-builder claims/estimation/grade reads resolve the
45
46
  // `lifecycle` kernel PORT (lc()); the grade-TREND read (builderRollingAvgGrade)
@@ -99,6 +100,50 @@ const artSharedKeyConfigured = () =>
99
100
  const discordIsLinked = (builderId) =>
100
101
  seams.resolveOptional('discord.isLinked', async () => false)(builderId);
101
102
 
103
+ // NAV_PERMISSION_ATOMS — the permission keys a hall NAV ITEM is allowed to gate
104
+ // on, and the only ones `/me` resolves (task 1003744).
105
+ //
106
+ // WHY A LIST AND NOT THE WHOLE SET. The shell's gateAllows() speaks RANK, so a
107
+ // nav item fronting a page gated on an ATOM had to borrow the nearest rank floor
108
+ // and hope the two agreed. The Board Room is where they stopped agreeing: its
109
+ // page asks `board.vote.cast`, which floors at XENOS on purpose so that widening
110
+ // the board works (modules/government/board.js), while its nav entry read the
111
+ // Government group's metic floor. This key lets an item declare the SAME atom its
112
+ // page is gated on, so the two cannot drift.
113
+ //
114
+ // It stays an ALLOWLIST rather than the builder's full resolved set because the
115
+ // full set is the shape of the instance's governance, and a cosmetic sidebar has
116
+ // no business shipping that to every page load. Adding an atom here is the
117
+ // deliberate act that lets a nav item gate on it.
118
+ //
119
+ // COSMETIC ONLY (ADR 0016). This decides who is OFFERED a link. The page gate
120
+ // (auth.js PAGE_GATES) and each data route's requirePermission are the wall, and
121
+ // they run regardless of what this says.
122
+ const NAV_PERMISSION_ATOMS = Object.freeze(['board.vote.cast']);
123
+
124
+ // navPermissionsFor(builderId) → { '<atom>': boolean } over NAV_PERMISSION_ATOMS,
125
+ // or null when authority could not be established (government off, port missing,
126
+ // resolver threw). NULL IS NOT "holds nothing" — the shell treats a missing or
127
+ // unreadable map as nothing-visible, matching gateAllows()'s `default: false` and
128
+ // the social mode's OFF-is-the-fallback rule, so an outage hides a link rather
129
+ // than offering one it cannot vouch for.
130
+ async function navPermissionsFor(builderId) {
131
+ try {
132
+ const government = seams.resolveOptional('government');
133
+ if (!government || typeof government.resolveBuilderPermissions !== 'function') return null;
134
+ const held = new Set((await government.resolveBuilderPermissions(builderId)) || []);
135
+ const out = {};
136
+ for (const atom of NAV_PERMISSION_ATOMS) out[atom] = held.has(atom);
137
+ return out;
138
+ } catch (err) {
139
+ // Message only — a resolver error can carry query text or row data. Through
140
+ // the structured logger rather than console.*, which is ratcheted in server
141
+ // paths (scripts/gds/fitness-ratchets.js).
142
+ log.error({ err: err && err.message }, 'GET /me nav permissions');
143
+ return null;
144
+ }
145
+ }
146
+
102
147
  module.exports = function buildMeRouter() {
103
148
  const router = express.Router();
104
149
 
@@ -134,6 +179,7 @@ module.exports = function buildMeRouter() {
134
179
  recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
135
180
  behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
136
181
  taskRecommendationCount, boardVotesPending, socialMode, mingleIntroductionCount,
182
+ navPermissions,
137
183
  ] = await Promise.all([
138
184
  db.getBuilderById(req.builder.id),
139
185
  lc().getActiveClaimsForBuilder(req.builder.id),
@@ -256,6 +302,10 @@ module.exports = function buildMeRouter() {
256
302
  return await lifecycle.openMingleIntroductionsFor(req.builder.id);
257
303
  } catch (_) { return 0; }
258
304
  })(),
305
+ // task 1003744: the resolved answer for each atom a nav item may gate on.
306
+ // Same fan-out slot as the reads above, but it fails CLOSED rather than
307
+ // open — null hides the link. See navPermissionsFor.
308
+ navPermissionsFor(req.builder.id),
259
309
  ]);
260
310
  // V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
261
311
  // monthly budget. Surfaced here so the CLI (session-start ping) and the hall
@@ -321,6 +371,12 @@ module.exports = function buildMeRouter() {
321
371
  // when lifecycle is disabled or the read failed, and the shell treats null
322
372
  // as OFF — a mode that cannot be read is not on.
323
373
  social: socialMode,
374
+ // task 1003744: { '<atom>': boolean } for each permission key a hall nav
375
+ // item may gate on (NAV_PERMISSION_ATOMS above), so an item can declare the
376
+ // SAME atom its page is gated on instead of borrowing the nearest rank
377
+ // floor. null when authority could not be established, which the shell
378
+ // reads as nothing-visible. Cosmetic only — ADR 0016, the server enforces.
379
+ permissions: navPermissions,
324
380
  unlocked,
325
381
  locked_with_progress: lockedWithProgress,
326
382
  streak: {
package/src/module-api.js CHANGED
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.631'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.633'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
59
59
 
60
60
  // A namespaced logger so a module's log lines are attributable + consistent.
61
61
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -55,6 +55,32 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
55
55
  replace(target) { replaced.push(String(target)); },
56
56
  };
57
57
 
58
+ // The gated nodes applyAccess() settles, parsed back out of the sidebar markup
59
+ // on demand and MEMOISED by key, so a test can call applyAccess and then read
60
+ // what each nav item's `hidden` became. Lazy because the markup does not exist
61
+ // until renderSidebar() runs, and memoised because applyAccess runs repeatedly
62
+ // (once at boot with null, then per /me) and must settle the same objects.
63
+ // Without this the stub returned [] and no gate was ever exercised — which is
64
+ // how the sidebar's whole gating layer went untested (task 1003744).
65
+ const gatedEls = new Map();
66
+ const attr = (tag, name) => (tag.match(new RegExp('\\b' + name + '="([^"]*)"')) || [])[1];
67
+ function gatedNodes() {
68
+ const html = els.has('app-sidebar') ? String(els.get('app-sidebar').innerHTML) : '';
69
+ const out = [];
70
+ for (const tag of html.split('<')) {
71
+ const gate = attr(tag, 'data-gate');
72
+ if (gate === undefined) continue;
73
+ const key = attr(tag, 'id') || ('head:' + attr(tag, 'data-group'));
74
+ if (!gatedEls.has(key)) gatedEls.set(key, { key, dataset: {}, hidden: true });
75
+ const el = gatedEls.get(key);
76
+ el.dataset.gate = gate;
77
+ const mod = attr(tag, 'data-module');
78
+ if (mod) el.dataset.module = mod;
79
+ out.push(el);
80
+ }
81
+ return out;
82
+ }
83
+
58
84
  const documentObj = {
59
85
  body: { dataset: { page } },
60
86
  documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
@@ -62,7 +88,7 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
62
88
  if (!els.has(id)) els.set(id, makeEl());
63
89
  return els.get(id);
64
90
  },
65
- querySelectorAll() { return []; },
91
+ querySelectorAll(sel) { return sel === '#app-sidebar [data-gate]' ? gatedNodes() : []; },
66
92
  createElement() { return makeEl(); },
67
93
  addEventListener() {},
68
94
  dispatchEvent() {},
@@ -97,6 +123,13 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
97
123
  viewHidden: (v) => documentObj.getElementById('view-' + v).hidden,
98
124
  shell: windowObj.OTBShell,
99
125
  replaced,
126
+ // navHidden('board-room') → is that item hidden right now? Read AFTER an
127
+ // applyAccess call; before one, everything is hidden (the boot default).
128
+ navHidden: (id) => {
129
+ const el = gatedEls.get('nav-' + id);
130
+ assert.ok(el, `nav item ${id} rendered`);
131
+ return el.hidden;
132
+ },
100
133
  };
101
134
  }
102
135
 
@@ -119,9 +152,32 @@ function parseNav(html) {
119
152
 
120
153
  // Permissiveness ladder, widest first. A group head gated MORE widely than every
121
154
  // one of its items renders a label over nothing.
122
- const GATE_WIDTH = { always: 0, signed: 1, work: 1, member: 2, archon: 3 };
155
+ //
156
+ // 'government' was MISSING here until task 1003744, which made every comparison
157
+ // against it `undefined < n` — false — so the Government group passed this check
158
+ // vacuously rather than by being right. It sits between member and archon (ADR
159
+ // 0157: metic-or-archon).
160
+ //
161
+ // A `perm:<atom>` gate (task 1003744) is NOT on the rank ladder at all, and the
162
+ // client genuinely cannot place it: the atom's floor lives in the government
163
+ // catalog, server-side, and the whole reason the key exists is that an atom may
164
+ // float BELOW the rank its neighbours read. So it scores 0 — as wide as 'always',
165
+ // the only assumption that cannot understate its reach.
166
+ const GATE_WIDTH = { always: 0, signed: 1, work: 1, member: 2, government: 3, archon: 4 };
167
+ const widthOf = (gate) => (String(gate).startsWith('perm:') ? 0 : GATE_WIDTH[gate]);
123
168
  const headOutOfRange = (g) =>
124
- g.headGate != null && GATE_WIDTH[g.headGate] < Math.min(...g.items.map((i) => GATE_WIDTH[i.gate]));
169
+ g.headGate != null && widthOf(g.headGate) < Math.min(...g.items.map((i) => widthOf(i.gate)));
170
+
171
+ // THE OTHER DIRECTION, and the one that had no check (task 1003744). The rule
172
+ // above catches a head gated wider than every item — a label over nothing. Its
173
+ // mirror is an item gated WIDER than its own head: the head hides, the item does
174
+ // not, and the viewer gets an orphan link under no label. That is exactly what
175
+ // gating the Board Room honestly while leaving it in the Government group would
176
+ // have produced, and it is the shape the owner declined (2026-09-09) by moving
177
+ // the item into the unlabeled project group instead. Without this check nothing
178
+ // stops it being moved back.
179
+ const itemsWiderThanHead = (g) =>
180
+ (g.headGate == null ? [] : g.items.filter((i) => widthOf(i.gate) < widthOf(g.headGate)).map((i) => i.id));
125
181
 
126
182
  // A nav href must land: an index view hash, an external apex link, or a real
127
183
  // page file in the hall surface.
@@ -154,7 +210,15 @@ test('sidebar groups read by audience, in order', () => {
154
210
  // room and the flag table) sit side by side, both with the project's own work
155
211
  // rather than in Docs (reference) or You (a person). Pinning the order keeps a
156
212
  // new tab in the hall's most valuable nav real estate a reviewed decision.
157
- assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'ideas', 'copy-desk', 'studio'],
213
+ // task 1003744 (goal 1000111): 'board-room' joins the same group, after Tasks.
214
+ // It was in Government from the ADR 0266 carve, reading that group's metic floor
215
+ // because the shell could not express its real wall — the `board.vote.cast` ATOM,
216
+ // which floors at XENOS so a widened board can reach the room. Gating it honestly
217
+ // would have put a wider item under a narrower group head, so the OWNER'S CALL
218
+ // (2026-09-09) was to move it: a sitting is work the project does, beside Goals
219
+ // and Tasks, not one of Government's monitoring surfaces. The item is invisible
220
+ // to anyone without the atom, so most builders never see it here.
221
+ assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'board-room', 'ideas', 'copy-desk', 'studio'],
158
222
  'the unlabeled group is the project itself (task 1002425 order)');
159
223
  assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
160
224
  // People (task 1002577): the /scouting directory graduated into the hall.
@@ -177,11 +241,10 @@ test('sidebar groups read by audience, in order', () => {
177
241
  // 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
178
242
  // 'project-settings' joins the Government group at the metic floor (task 1003280,
179
243
  // goal 1000072) — it sits before 'government', which is the one archon-only item.
180
- // 'board-room' leads the group since ADR 0266: it is the one page here you ACT
181
- // on (a sitting waits on a decision; the rest is monitoring), the same ordering
182
- // rule the Community group's first item follows. Before that carve it had no
183
- // item at all it was a tab behind the one labelled "Permissions".
184
- assert.deepEqual(nav[4].items.map((i) => i.id), ['board-room', 'watch', 'harbor', 'gate', 'sessions', 'project-settings', 'government']);
244
+ // 'board-room' LED this group from the ADR 0266 carve until task 1003744 moved
245
+ // it to the project group see the note on nav[0]. What is left is rank surface
246
+ // throughout, which is what lets the group keep its metic head honestly.
247
+ assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'harbor', 'gate', 'sessions', 'project-settings', 'government']);
185
248
  });
186
249
 
187
250
  test('Economy lives in Docs and points at the reward-schedule page', () => {
@@ -242,11 +305,97 @@ test('no group head is gated wider than its own items', () => {
242
305
  assert.deepEqual(bad, [], 'a head gated wider than every item would render a label over nothing');
243
306
  });
244
307
 
308
+ // ── a nav item can gate on a PERMISSION, not just a rank (task 1003744) ─────
309
+
310
+ // The Board Room's page asks `board.vote.cast`, which floors at XENOS on purpose
311
+ // so that widening the board works. Its nav item read the Government group's
312
+ // metic floor, because gateAllows() spoke rank and nothing else — so a sub-Metic
313
+ // board member could reach the room by URL and by the board_votes deep link with
314
+ // no sidebar entry. These four tests are the DONE-WHEN.
315
+ //
316
+ // They drive the REAL applyAccess through the boot stub rather than restating
317
+ // gateAllows here, because what a builder sees is the settled `hidden`, and the
318
+ // rule under test is precisely that the client does not re-derive the answer.
319
+
320
+ test('THE DONE-WHEN: a xenos holding board.vote.cast is offered the Board Room', () => {
321
+ const b = boot();
322
+ b.shell.applyAccess({ authed: true, rank: 'xenos', permissions: { 'board.vote.cast': true } });
323
+ assert.equal(b.navHidden('board-room'), false, 'the atom is what opens the link, not the rank');
324
+ // The rank floor genuinely still binds everywhere else — this widened one item,
325
+ // not the sidebar. Watch is the neighbour it used to share a gate with.
326
+ assert.equal(b.navHidden('watch'), true, 'a xenos is still offered no Government page');
327
+ });
328
+
329
+ test('a builder WITHOUT the atom is not offered the Board Room, at any rank', () => {
330
+ const xenos = boot();
331
+ xenos.shell.applyAccess({ authed: true, rank: 'xenos', permissions: { 'board.vote.cast': false } });
332
+ assert.equal(xenos.navHidden('board-room'), true, 'holding nothing hides it');
333
+
334
+ // An ARCHON without the atom is the case the old rank gate got wrong in the
335
+ // other direction: it showed the link to every Metic+, atom or not.
336
+ const archon = boot();
337
+ archon.shell.applyAccess({ authed: true, rank: 'archon', permissions: { 'board.vote.cast': false } });
338
+ assert.equal(archon.navHidden('board-room'), true, 'rank does not substitute for the permission');
339
+ assert.equal(archon.navHidden('watch'), false, 'sanity: the archon does see the rank-gated pages');
340
+ });
341
+
342
+ test('an unreadable permission map hides the link — absent is not visible', () => {
343
+ // /me returns `permissions: null` when authority could not be established
344
+ // (government off, port missing, resolver threw). That is not "holds nothing",
345
+ // it is "cannot say", and it must hide — gateAllows()'s `default: false` and
346
+ // the social mode's OFF-is-the-fallback rule, applied.
347
+ for (const permissions of [null, undefined, 'yes', 0]) {
348
+ const b = boot();
349
+ b.shell.applyAccess({ authed: true, rank: 'archon', permissions });
350
+ assert.equal(b.navHidden('board-room'), true, `permissions=${JSON.stringify(permissions)} hides the link`);
351
+ }
352
+ // Signed OUT with a map somehow in hand is the same answer.
353
+ const out = boot();
354
+ out.shell.applyAccess({ authed: false, permissions: { 'board.vote.cast': true } });
355
+ assert.equal(out.navHidden('board-room'), true, 'a permission never implies a session');
356
+ });
357
+
358
+ test('the Board Room item declares the SAME atom its page is gated on', () => {
359
+ // The drift this task exists to close: the nav gate and the page gate must name
360
+ // one key. auth.js is the wall; the nav merely quotes it.
361
+ const item = parseNav(boot().sidebarHtml).flatMap((g) => g.items).find((i) => i.id === 'board-room');
362
+ assert.equal(item.gate, 'perm:board.vote.cast');
363
+ const authSrc = fs.readFileSync(path.join(ROOT, 'src/bongos/auth.js'), 'utf8');
364
+ assert.match(authSrc, /requireBoardVotePage: Object\.freeze\(\{ permission: 'board\.vote\.cast'/,
365
+ 'the page gate still asks the atom the nav item quotes');
366
+ // And the server must actually RESOLVE it, or the item can never be shown.
367
+ const meSrc = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/me.js'), 'utf8');
368
+ assert.match(meSrc, /NAV_PERMISSION_ATOMS = Object\.freeze\(\[[^\]]*'board\.vote\.cast'/,
369
+ '/me resolves the atom the nav item gates on');
370
+ });
371
+
245
372
  test('group-gate check actually CATCHES a head gated wider than its items', () => {
246
373
  assert.equal(headOutOfRange({ label: 'X', headGate: 'signed', items: [{ gate: 'member' }] }), true,
247
374
  'signed head over a member-only item is the bug');
248
375
  assert.equal(headOutOfRange({ label: 'X', headGate: 'member', items: [{ gate: 'member' }] }), false);
249
376
  assert.equal(headOutOfRange({ label: 'X', headGate: 'archon', items: [{ gate: 'archon' }] }), false);
377
+ // task 1003744: 'government' is on the ladder now — before it was undefined and
378
+ // every comparison against it was silently false, so the group passed vacuously.
379
+ assert.equal(headOutOfRange({ label: 'X', headGate: 'government', items: [{ gate: 'archon' }] }), true,
380
+ 'a metic-visible head over archon-only items renders a label over nothing');
381
+ assert.equal(headOutOfRange({ label: 'X', headGate: 'government', items: [{ gate: 'government' }] }), false);
382
+ });
383
+
384
+ test('no nav item is more permissive than its own group head', () => {
385
+ const bad = parseNav(boot().sidebarHtml).flatMap(itemsWiderThanHead);
386
+ assert.deepEqual(bad, [], 'an item wider than its head renders as an orphan link under no label');
387
+ });
388
+
389
+ test('the orphan-link check CATCHES the arrangement the owner declined', () => {
390
+ // Board Room gated on its real atom, left inside the Government group: the
391
+ // xenos board member sees the link and not the "Government" label above it.
392
+ assert.deepEqual(
393
+ itemsWiderThanHead({ headGate: 'government', items: [{ id: 'board-room', gate: 'perm:board.vote.cast' }, { id: 'watch', gate: 'government' }] }),
394
+ ['board-room']);
395
+ // The same item in the unlabeled project group (no head) is fine — that is
396
+ // where it lives.
397
+ assert.deepEqual(itemsWiderThanHead({ headGate: null, items: [{ id: 'board-room', gate: 'perm:board.vote.cast' }] }), []);
398
+ assert.deepEqual(itemsWiderThanHead({ headGate: 'signed', items: [{ id: 'people', gate: 'government' }] }), []);
250
399
  });
251
400
 
252
401
  test('#leaderboard REDIRECTS to /roster from the index, and only from the index', () => {
@@ -206,3 +206,48 @@ test('devsecops joined the roster (task 1003124) — the enforcement territory g
206
206
  assert.equal(covers('.claude/skills/builder-ship/SKILL.md'), false, 'skills stay with the module that ships them');
207
207
  assert.equal(covers('scripts/gds/ship.js'), false, 'the ship pipeline belongs to lifecycle, not devsecops');
208
208
  });
209
+
210
+ test('methodology joined the roster (task 1002989) — the instruction surfaces working area 6 walls', () => {
211
+ // Before this, the root CLAUDE.md, docs/, .claude/skills/ and the two session-
212
+ // composing scripts were covered by NO key, so goal 1000095 could not wall the
213
+ // kernel↔pack split (task 1002990) — the work that rewrites CLAUDE.md, authors the
214
+ // Engineer/Artist/Ideator packs, and deletes the three discipline skills.
215
+ assert.ok(scope.isModuleKey('methodology'), 'roster missing module "methodology"');
216
+ const globs = scope.globsForModules(['methodology']);
217
+ assert.ok(globs.includes('CLAUDE.md'), 'methodology owns the always-loaded instruction root');
218
+ assert.ok(globs.includes('docs/'), 'methodology owns the docs library');
219
+ assert.ok(globs.includes('.claude/skills/'), 'the slash-command skills are the methodology definition surface');
220
+ assert.ok(globs.includes('scripts/gds/start.js'), 'the session-opening composer travels with the domain');
221
+ // Derived, not hand-set: CLAUDE.md, docs/canonical-permissions.md and the two trust
222
+ // ADRs sit in the ADR 0043 protected-surfaces registry, so this key MUST derive
223
+ // protected — creating or widening a goal into it is Archon-only (BV1.R57).
224
+ assert.equal(scope.isProtectedModule('methodology'), true, 'methodology must derive protected (CLAUDE.md is a protected surface)');
225
+ assert.equal(
226
+ scope.scopeForModule('methodology').protected,
227
+ scope.isProtected(globs),
228
+ 'protected must be the faithful derivation, never a literal',
229
+ );
230
+ // Asserted through the REAL consumer (directional containment), not re-implemented.
231
+ const covers = (touch) => gsc.scopeSubset(['methodology'], [touch]).ok;
232
+ for (const touch of [
233
+ 'CLAUDE.md', // the kernel half of the split (1002990)
234
+ '.claude/skills/dev/SKILL.md', // one of the three discipline skills it deletes
235
+ '.claude/skills/ideate/SKILL.md',
236
+ '.claude/skills/paint/SKILL.md',
237
+ '.claude/rules/content-charter.md', // pre-carved for task 1003687
238
+ 'docs/adr/0264-the-ten-working-areas.md', // the ADR library
239
+ 'docs/handoff-template.md',
240
+ 'scripts/gds/start.js', // composes a session's opening context
241
+ 'scripts/gds/discipline-modes.json', // the discipline→mode table it routes on
242
+ ]) {
243
+ assert.equal(covers(touch), true, `wall does not cover ${touch}`);
244
+ }
245
+ // …and still BOUNDS: the trust kernel, the sibling .claude definition dirs, and the
246
+ // enforcement tooling devsecops owns all stay outside this wall.
247
+ assert.equal(covers('src/bongos/auth.js'), false, 'the wall must not cover the kernel auth core');
248
+ assert.equal(covers('.claude/settings.json'), false, 'the agent settings belong to devsecops');
249
+ assert.equal(covers('.claude/scheduled-tasks/bfg/SKILL.md'), false, 'scheduled-tasks belongs to autonomy, not methodology');
250
+ assert.equal(covers('.claude/agents/reviewer.md'), false, 'agent definitions belong to agents, not methodology');
251
+ assert.equal(covers('scripts/gds/fitness.js'), false, 'the fitness tool stayed with devsecops (task 1003124)');
252
+ assert.equal(covers('scripts/gds/ship.js'), false, 'the ship pipeline belongs to lifecycle');
253
+ });