@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.
- package/.bongos-core.json +25 -20
- package/docs/adr/0273-hall-nav-can-gate-on-a-permission.md +98 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +114 -114
- package/docs/copy-registry.json +127 -127
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/hall-render.js +5 -0
- package/modules/hall-ui/public/shell.js +58 -17
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/module-scope-map.js +47 -0
- package/src/bongos/routes/me.js +56 -0
- package/src/module-api.js +1 -1
- package/tests/hall_nav.mjs +158 -9
- package/tests/module-scope-map.mjs +45 -0
|
@@ -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
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -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.
|
|
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');
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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'
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
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
|
+
});
|