@bongos/core 1.19.641 → 1.19.643
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 +23 -23
- package/docs/copy-inventory.md +114 -114
- package/docs/copy-registry.json +127 -127
- package/docs/module-api-changelog.md +4 -0
- package/modules/builder-settings/builder-needs.js +14 -0
- package/modules/hall-ui/public/hall-render.js +5 -0
- package/modules/hall-ui/public/shell.js +95 -5
- package/modules/hall-ui/public/style.css +38 -0
- package/modules/lifecycle/rot.js +51 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +83 -5
- package/src/bongos/db.js +27 -5
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +95 -0
- package/tests/government_board_summons.mjs +19 -0
- package/tests/government_rank_sync.mjs +127 -0
- package/tests/hall_nav.mjs +150 -1
- package/tests/rot_inbox.mjs +79 -3
|
@@ -255,6 +255,98 @@ t('disabledModuleSkills: only skills of DISABLED modules are collected (default
|
|
|
255
255
|
rmSync(core, { recursive: true, force: true }); rmSync(inst, { recursive: true, force: true });
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
+
// ---- withdrawing a disabled module's skills on an EXISTING instance (task 1003632) ----
|
|
259
|
+
// The bug: materializeClaude honoured `{"<key>": false}` on a FRESH instance (nothing
|
|
260
|
+
// was copied) but only ever declined to COPY — so on an instance that already had the
|
|
261
|
+
// files, `bongos upgrade` left them in .claude/skills/ for the harness to keep loading.
|
|
262
|
+
// The withdrawal is PROVENANCE-GATED, which is the property that matters most here: a
|
|
263
|
+
// skill the instance's owner wrote by hand is never in the manifest, so it must survive
|
|
264
|
+
// even when a module declares the very same name.
|
|
265
|
+
|
|
266
|
+
// Build a core with one module shipping two skills, and an instance with the module ON.
|
|
267
|
+
function withdrawFixture() {
|
|
268
|
+
const core = mkdtempSync(join(tmpdir(), 'wd-core-'));
|
|
269
|
+
const inst = mkdtempSync(join(tmpdir(), 'wd-inst-'));
|
|
270
|
+
const modDir = join(core, 'modules', 'foo');
|
|
271
|
+
mkdirSync(join(modDir, 'skills', 'alpha'), { recursive: true });
|
|
272
|
+
mkdirSync(join(modDir, 'skills', 'beta'), { recursive: true });
|
|
273
|
+
mkdirSync(join(core, '.claude', 'skills'), { recursive: true });
|
|
274
|
+
writeFileSync(join(modDir, 'module.json'), JSON.stringify({ key: 'foo', default: true, contributes: { skills: ['alpha', 'beta'] } }));
|
|
275
|
+
writeFileSync(join(modDir, 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\n---\nbody\n');
|
|
276
|
+
writeFileSync(join(modDir, 'skills', 'beta', 'SKILL.md'), '---\nname: beta\n---\nbody\n');
|
|
277
|
+
return { core, inst, modDir };
|
|
278
|
+
}
|
|
279
|
+
const disable = (inst, key) => {
|
|
280
|
+
mkdirSync(join(inst, 'config'), { recursive: true });
|
|
281
|
+
writeFileSync(join(inst, 'config', 'modules.json'), JSON.stringify({ modules: { [key]: false } }));
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
t('materializeClaude: a module disabled on an EXISTING instance has its skills WITHDRAWN, not left to linger', () => {
|
|
285
|
+
const { core, inst } = withdrawFixture();
|
|
286
|
+
// upgrade 1 — module on: both land, and provenance is recorded
|
|
287
|
+
const first = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
288
|
+
assert.equal(first.moduleSkills, 2);
|
|
289
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', 'alpha', 'SKILL.md')));
|
|
290
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', 'beta', 'SKILL.md')));
|
|
291
|
+
assert.deepEqual(first.withdrawnSkills, [], 'nothing to withdraw on the way in');
|
|
292
|
+
const mani = m.readInstanceSkillsManifest(join(inst, '.claude', 'skills'));
|
|
293
|
+
assert.deepEqual(Object.keys(mani.skills).sort(), ['alpha', 'beta'], 'the run records what it landed');
|
|
294
|
+
|
|
295
|
+
// upgrade 2 — module off: THIS is the bug. Both dirs must go.
|
|
296
|
+
disable(inst, 'foo');
|
|
297
|
+
const second = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
298
|
+
assert.deepEqual(second.withdrawnSkills, ['alpha', 'beta']);
|
|
299
|
+
assert.ok(!existsSync(join(inst, '.claude', 'skills', 'alpha')), 'alpha withdrawn');
|
|
300
|
+
assert.ok(!existsSync(join(inst, '.claude', 'skills', 'beta')), 'beta withdrawn');
|
|
301
|
+
assert.deepEqual(Object.keys(m.readInstanceSkillsManifest(join(inst, '.claude', 'skills')).skills), [], 'provenance now records nothing landed');
|
|
302
|
+
|
|
303
|
+
// upgrade 3 — idempotent: nothing left to withdraw, no throw on the absent dirs
|
|
304
|
+
const third = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
305
|
+
assert.deepEqual(third.withdrawnSkills, []);
|
|
306
|
+
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
t('materializeClaude: a skill the module STOPPED declaring is withdrawn too', () => {
|
|
310
|
+
const { core, inst, modDir } = withdrawFixture();
|
|
311
|
+
m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
312
|
+
// the module keeps only alpha
|
|
313
|
+
writeFileSync(join(modDir, 'module.json'), JSON.stringify({ key: 'foo', default: true, contributes: { skills: ['alpha'] } }));
|
|
314
|
+
const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
315
|
+
assert.deepEqual(res.withdrawnSkills, ['beta']);
|
|
316
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', 'alpha')), 'a still-declared skill stays');
|
|
317
|
+
assert.ok(!existsSync(join(inst, '.claude', 'skills', 'beta')));
|
|
318
|
+
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
t('materializeClaude: a HAND-WRITTEN skill is never withdrawn, even when a disabled module declares its name', () => {
|
|
322
|
+
const { core, inst } = withdrawFixture();
|
|
323
|
+
// The owner wrote .claude/skills/alpha themselves; no materialize ever landed it,
|
|
324
|
+
// so no manifest names it. Disabling foo must not touch it.
|
|
325
|
+
mkdirSync(join(inst, '.claude', 'skills', 'alpha'), { recursive: true });
|
|
326
|
+
writeFileSync(join(inst, '.claude', 'skills', 'alpha', 'SKILL.md'), 'MINE — hand written\n');
|
|
327
|
+
disable(inst, 'foo');
|
|
328
|
+
const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
329
|
+
assert.deepEqual(res.withdrawnSkills, [], 'no provenance for it, so it is not ours to delete');
|
|
330
|
+
assert.equal(readFileSync(join(inst, '.claude', 'skills', 'alpha', 'SKILL.md'), 'utf8'), 'MINE — hand written\n', 'the owner\'s file is untouched');
|
|
331
|
+
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
t('materializeClaude: --dry-run reports the withdrawal without performing it; adopt mode declines to withdraw', () => {
|
|
335
|
+
const { core, inst } = withdrawFixture();
|
|
336
|
+
m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
337
|
+
disable(inst, 'foo');
|
|
338
|
+
|
|
339
|
+
const dry = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: true });
|
|
340
|
+
assert.deepEqual(dry.withdrawnSkills, ['alpha', 'beta'], 'reports what it would withdraw');
|
|
341
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', 'alpha')), 'a dry run deletes nothing');
|
|
342
|
+
|
|
343
|
+
const adopt = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false, overwrite: false });
|
|
344
|
+
assert.deepEqual(adopt.withdrawnSkills, [], 'adopt never clobbers what the owner has');
|
|
345
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', 'alpha')));
|
|
346
|
+
assert.ok(adopt.notes.some((n) => /adopt mode does not withdraw/.test(n)), 'and says so');
|
|
347
|
+
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
348
|
+
});
|
|
349
|
+
|
|
258
350
|
// ---- module-owned skills (task 1003321 / ADR 0198) ---------------------------
|
|
259
351
|
// A module ships a skill from modules/<key>/skills/<name>/ (its own) or
|
|
260
352
|
// modules/<key>/skills/vendor/<name>/ (third-party + PROVENANCE.md); only a DECLARED
|
|
@@ -473,6 +565,9 @@ t('--module-skills-only: an instance materialised FROM a core that carries mater
|
|
|
473
565
|
assert.ok(res.moduleSkills >= 2, 'alpha landed through step 1b');
|
|
474
566
|
assert.match(readFileSync(join(inst, '.claude', 'skills', 'alpha', 'SKILL.md'), 'utf8'), /bongos ship 1/, 'transformed on the way into the instance');
|
|
475
567
|
for (const f of [m.MODULE_SKILLS_MANIFEST, '.gitignore']) assert.ok(!existsSync(join(inst, '.claude', 'skills', f)), `${f} is not shipped to an instance`);
|
|
568
|
+
// The instance writes its OWN provenance record under a different name (task 1003632)
|
|
569
|
+
// — it is not the core's dotfile leaking across the boundary the line above guards.
|
|
570
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', m.INSTANCE_SKILLS_MANIFEST)), 'the instance records what it landed, under its own filename');
|
|
476
571
|
assert.ok(existsSync(join(inst, '.claude', 'skills', 'core-one', 'SKILL.md')) && existsSync(join(inst, '.claude', 'skills', 'shared', 'SKILL.md')), 'the core skills still land');
|
|
477
572
|
rmSync(core, { recursive: true, force: true }); rmSync(inst, { recursive: true, force: true });
|
|
478
573
|
});
|
|
@@ -200,6 +200,9 @@ test('pending votes are action_needed and deep-link the oldest sitting', () => {
|
|
|
200
200
|
assert.equal(n.id, 'board_votes');
|
|
201
201
|
assert.equal(n.state, 'action_needed');
|
|
202
202
|
assert.equal(n.action.href, '/board-room?item=1');
|
|
203
|
+
// task 1003737: the number, machine-readable. The message has always carried it
|
|
204
|
+
// in prose, which a sentence can render and the nav badge cannot.
|
|
205
|
+
assert.equal(n.count, 9, 'the need states its count for a badge to read');
|
|
203
206
|
// The item is a query param, because `/board-room` reads `?item=` and a
|
|
204
207
|
// FRAGMENT would never reach the server (ADR 0266). The old
|
|
205
208
|
// `/government…#board-room` form survives only as government.js's
|
|
@@ -207,6 +210,22 @@ test('pending votes are action_needed and deep-link the oldest sitting', () => {
|
|
|
207
210
|
assert.ok(!n.action.href.includes('#board-room'), 'never emits the retired hash form');
|
|
208
211
|
});
|
|
209
212
|
|
|
213
|
+
test('the founding grace keeps the count but drops the demand', () => {
|
|
214
|
+
// The badge keys on state, not on the number, precisely because of this: the
|
|
215
|
+
// grace rewrites the row to 'covered' and the count rides through the spread.
|
|
216
|
+
// A renderer keyed on `count` alone would shout through a new instance's
|
|
217
|
+
// first days, which is the one thing the grace exists to prevent.
|
|
218
|
+
const out = needs.computeNeeds({
|
|
219
|
+
rank: 'archon', ownKeySet: true,
|
|
220
|
+
pendingBoardVotes: { count: 5, first_item_id: '1' },
|
|
221
|
+
isFoundingOwner: true, founderCreatedAt: new Date().toISOString(),
|
|
222
|
+
});
|
|
223
|
+
const row = out.items.find((i) => i.id === 'board_votes');
|
|
224
|
+
assert.equal(row.count, 5, 'the count survives');
|
|
225
|
+
assert.equal(row.state, 'covered', 'but it is no longer a demand');
|
|
226
|
+
assert.equal(out.has_action_needed, false);
|
|
227
|
+
});
|
|
228
|
+
|
|
210
229
|
test('a count with no item id still links the room', () => {
|
|
211
230
|
const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 2 } });
|
|
212
231
|
assert.equal(n.action.href, '/board-room');
|
|
@@ -326,3 +326,130 @@ test('the writer EXECUTES its plan: a custom rank survives a rank change', async
|
|
|
326
326
|
const del = c.find(/DELETE FROM government_builder_ranks/);
|
|
327
327
|
assert.deepEqual(del.params, [11, ['archon']]);
|
|
328
328
|
});
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// offboard + reactivation (task 1003193)
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
//
|
|
334
|
+
// deactivateBuilder forces rank='xenos' by direct SQL — not through
|
|
335
|
+
// setBuilderRank/_applyRankChangeTx, the only paths that rotated the role — so the
|
|
336
|
+
// offboarded builder's OLD seeded rank-role survived in government_builder_ranks,
|
|
337
|
+
// and getRankKeysForBuilder has no builders.status filter. The stale row is not a
|
|
338
|
+
// live grant while they are inactive; REACTIVATION is the exposure, because
|
|
339
|
+
// reactivateBuilder restores status='active' without touching rank, and a
|
|
340
|
+
// reactivated ex-Archon sitting at builders.rank='xenos' then matches
|
|
341
|
+
// getActiveBuildersWithRank / builderHoldsRankActive on the stale archon row.
|
|
342
|
+
//
|
|
343
|
+
// Asserted by EXECUTION, like the writer tests above: withTx is stubbed on
|
|
344
|
+
// db-kernel BEFORE src/bongos/db.js is required (it destructures at load), the
|
|
345
|
+
// government port is wired to the REAL syncRank, and both functions run against one
|
|
346
|
+
// recording client — so the rotation being on the same transaction, and after the
|
|
347
|
+
// rank write, is a fact about the code rather than a regex over it.
|
|
348
|
+
|
|
349
|
+
const kernel = require(path.join(ROOT, 'src/bongos/db-kernel.js'));
|
|
350
|
+
const seams = require(path.join(ROOT, 'src/module-seams.js'));
|
|
351
|
+
if (!seams.hasProvider('government.rankSync')) {
|
|
352
|
+
seams.registerProvider('government.rankSync', { syncRank: govDb.syncRank });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// One client for the whole transaction: the builders/claims/sessions statements the
|
|
356
|
+
// exit path issues AND the two government reads syncRank makes. Order the matchers
|
|
357
|
+
// specific-first — the assignments read also names government_ranks (it joins it).
|
|
358
|
+
function exitTxClient({ builderId, rank, status, held = [], newRank, newStatus }) {
|
|
359
|
+
const calls = [];
|
|
360
|
+
return {
|
|
361
|
+
calls,
|
|
362
|
+
find: (re) => calls.find((c) => re.test(c.sql)),
|
|
363
|
+
indexOf: (re) => calls.findIndex((c) => re.test(c.sql)),
|
|
364
|
+
async query(sql, params) {
|
|
365
|
+
calls.push({ sql, params });
|
|
366
|
+
if (/INSERT INTO|DELETE FROM/.test(sql)) return { rows: [] };
|
|
367
|
+
if (/FROM government_builder_ranks br/.test(sql)) return { rows: held };
|
|
368
|
+
if (/FROM government_ranks/.test(sql)) return { rows: SEEDED };
|
|
369
|
+
if (/FROM builders WHERE id = \$1 FOR UPDATE/.test(sql)) {
|
|
370
|
+
return { rows: [{ id: builderId, rank, status }] };
|
|
371
|
+
}
|
|
372
|
+
if (/FROM claims/.test(sql)) return { rows: [] };
|
|
373
|
+
if (/UPDATE builders/.test(sql)) {
|
|
374
|
+
return { rows: [{ id: builderId, rank: newRank, status: newStatus }] };
|
|
375
|
+
}
|
|
376
|
+
return { rows: [] };
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let txClient = null;
|
|
382
|
+
kernel.withTx = async (fn) => fn(txClient);
|
|
383
|
+
const coreDb = require(path.join(ROOT, 'src/bongos/db.js'));
|
|
384
|
+
|
|
385
|
+
test('offboarding an Archon removes the archon rank-role in the deactivation transaction', async () => {
|
|
386
|
+
txClient = exitTxClient({
|
|
387
|
+
builderId: 25, rank: 'archon', status: 'active',
|
|
388
|
+
held: [seededRank_('archon')], newRank: 'xenos', newStatus: 'inactive',
|
|
389
|
+
});
|
|
390
|
+
await coreDb.deactivateBuilder({ builderId: 25, reason: 'left the team', actorBuilderId: 3 });
|
|
391
|
+
|
|
392
|
+
const del = txClient.find(/DELETE FROM government_builder_ranks/);
|
|
393
|
+
assert.ok(del, 'deactivateBuilder must rotate the seeded rank-role, not only write rank=xenos');
|
|
394
|
+
assert.deepEqual(del.params, [25, ['archon']], 'the stale archon assignment must be the row deleted');
|
|
395
|
+
const ins = txClient.find(/INSERT INTO government_builder_ranks/);
|
|
396
|
+
assert.deepEqual(ins.params, [25, ['xenos']], 'and the sandboxed rank-role takes its place');
|
|
397
|
+
|
|
398
|
+
assert.ok(txClient.indexOf(/DELETE FROM government_builder_ranks/) > txClient.indexOf(/UPDATE builders/),
|
|
399
|
+
'the rotation must follow the rank write inside the same transaction');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('reactivation re-syncs the rank-role, so a stale-high role cannot come back live', async () => {
|
|
403
|
+
// The pre-fix residue: rank was forced to xenos but the archon assignment stayed.
|
|
404
|
+
// Flipping status back to 'active' is the moment it becomes a live grant again.
|
|
405
|
+
txClient = exitTxClient({
|
|
406
|
+
builderId: 25, rank: 'xenos', status: 'inactive',
|
|
407
|
+
held: [seededRank_('archon')], newRank: 'xenos', newStatus: 'active',
|
|
408
|
+
});
|
|
409
|
+
await coreDb.reactivateBuilder({ builderId: 25, actorBuilderId: 3 });
|
|
410
|
+
|
|
411
|
+
const del = txClient.find(/DELETE FROM government_builder_ranks/);
|
|
412
|
+
assert.ok(del, 'reactivateBuilder must sync the rank-role to the row\'s current rank');
|
|
413
|
+
assert.deepEqual(del.params, [25, ['archon']],
|
|
414
|
+
'a reactivated ex-Archon must not resolve Archon permissions on the stale row');
|
|
415
|
+
assert.deepEqual(txClient.find(/INSERT INTO government_builder_ranks/).params, [25, ['xenos']]);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('a consistent builder is reactivated with no government write at all', async () => {
|
|
419
|
+
// The sync is a repair, not a rewrite: it must not churn rows on every offboard
|
|
420
|
+
// round-trip once the assignment already agrees with builders.rank.
|
|
421
|
+
txClient = exitTxClient({
|
|
422
|
+
builderId: 26, rank: 'xenos', status: 'inactive',
|
|
423
|
+
held: [seededRank_('xenos'), customRank('release-manager')], newRank: 'xenos', newStatus: 'active',
|
|
424
|
+
});
|
|
425
|
+
await coreDb.reactivateBuilder({ builderId: 26, actorBuilderId: 3 });
|
|
426
|
+
|
|
427
|
+
assert.ok(!txClient.find(/(INSERT INTO|DELETE FROM) government_builder_ranks/),
|
|
428
|
+
'no rotation is due — and the custom rank must survive untouched');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('a custom rank survives an offboard (only the seeded rank-role rotates)', async () => {
|
|
432
|
+
txClient = exitTxClient({
|
|
433
|
+
builderId: 27, rank: 'metic', status: 'active',
|
|
434
|
+
held: [seededRank_('metic'), customRank('release-manager')], newRank: 'xenos', newStatus: 'inactive',
|
|
435
|
+
});
|
|
436
|
+
await coreDb.deactivateBuilder({ builderId: 27, reason: null, actorBuilderId: 3 });
|
|
437
|
+
|
|
438
|
+
assert.deepEqual(txClient.find(/DELETE FROM government_builder_ranks/).params, [27, ['metic']],
|
|
439
|
+
'offboarding must never revoke an Archon-assigned custom rank');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('both exit paths rotate the role on the transaction client, uncaught', async () => {
|
|
443
|
+
// The posture half, which execution cannot show: no try/catch around the call, so
|
|
444
|
+
// a rotation that cannot be written rolls the status change back with it.
|
|
445
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/db.js'), 'utf8');
|
|
446
|
+
for (const fn of ['deactivateBuilder', 'reactivateBuilder']) {
|
|
447
|
+
const body = src.slice(src.indexOf(`async function ${fn}(`));
|
|
448
|
+
const end = body.indexOf('\n}\n');
|
|
449
|
+
const scope = body.slice(0, end === -1 ? body.length : end);
|
|
450
|
+
const sync = scope.indexOf('await syncGovernanceRankRole(client');
|
|
451
|
+
assert.ok(sync !== -1, `${fn}: must rotate the seeded rank-role inside its transaction`);
|
|
452
|
+
assert.ok(!/try\s*{[\s\S]*syncGovernanceRankRole/.test(scope),
|
|
453
|
+
`${fn}: the rotation must not be swallowed — a failed rotation has to roll the write back`);
|
|
454
|
+
}
|
|
455
|
+
});
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -81,6 +81,31 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
|
|
|
81
81
|
return out;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// task 1003737: the badge nodes applyCounts() settles. They are THE SAME
|
|
85
|
+
// element objects gatedNodes() hands back — keyed by the same id — because
|
|
86
|
+
// applyCounts reads `el.hidden`, which the gate loop is what sets. A separate
|
|
87
|
+
// stub object here would read a hidden that nothing ever wrote, and the
|
|
88
|
+
// "a badge never out-runs its gate" assertion would pass vacuously.
|
|
89
|
+
function countNodes() {
|
|
90
|
+
const html = els.has('app-sidebar') ? String(els.get('app-sidebar').innerHTML) : '';
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const tag of html.split('<')) {
|
|
93
|
+
const count = attr(tag, 'data-count');
|
|
94
|
+
if (count === undefined) continue;
|
|
95
|
+
const key = attr(tag, 'id');
|
|
96
|
+
if (!gatedEls.has(key)) gatedEls.set(key, { key, dataset: {}, hidden: true });
|
|
97
|
+
const el = gatedEls.get(key);
|
|
98
|
+
el.dataset.count = count;
|
|
99
|
+
if (!el._badge) {
|
|
100
|
+
el._badge = { hidden: true, textContent: '' };
|
|
101
|
+
el._label = { textContent: '' };
|
|
102
|
+
el.querySelector = (sel) => (sel === '[data-badge]' ? el._badge : sel === '[data-badge-label]' ? el._label : null);
|
|
103
|
+
}
|
|
104
|
+
out.push(el);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
84
109
|
const documentObj = {
|
|
85
110
|
body: { dataset: { page } },
|
|
86
111
|
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
|
|
@@ -88,7 +113,11 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
|
|
|
88
113
|
if (!els.has(id)) els.set(id, makeEl());
|
|
89
114
|
return els.get(id);
|
|
90
115
|
},
|
|
91
|
-
querySelectorAll(sel) {
|
|
116
|
+
querySelectorAll(sel) {
|
|
117
|
+
if (sel === '#app-sidebar [data-gate]') return gatedNodes();
|
|
118
|
+
if (sel === '#app-sidebar .nav-item[data-count]') return countNodes();
|
|
119
|
+
return [];
|
|
120
|
+
},
|
|
92
121
|
createElement() { return makeEl(); },
|
|
93
122
|
addEventListener() {},
|
|
94
123
|
dispatchEvent() {},
|
|
@@ -130,6 +159,14 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
|
|
|
130
159
|
assert.ok(el, `nav item ${id} rendered`);
|
|
131
160
|
return el.hidden;
|
|
132
161
|
},
|
|
162
|
+
// task 1003737: what the badge on that item says right now — null when it is
|
|
163
|
+
// not showing, which is the state ZERO IS SILENT is about.
|
|
164
|
+
navBadge: (id) => {
|
|
165
|
+
const el = gatedEls.get('nav-' + id);
|
|
166
|
+
assert.ok(el, `nav item ${id} rendered`);
|
|
167
|
+
assert.ok(el._badge, `nav item ${id} declares a count`);
|
|
168
|
+
return el._badge.hidden ? null : { text: el._badge.textContent, label: el._label.textContent };
|
|
169
|
+
},
|
|
133
170
|
};
|
|
134
171
|
}
|
|
135
172
|
|
|
@@ -446,3 +483,115 @@ test('collapsible groups carry a stable storage key (collapse persistence)', ()
|
|
|
446
483
|
assert.ok(!/nav-group--collapsible[^>]*>(?!<button)/.test(html.split('<div class="nav-group')[1] || ''),
|
|
447
484
|
'the unlabeled project group stays always-expanded');
|
|
448
485
|
});
|
|
486
|
+
|
|
487
|
+
// ── a nav item can carry a COUNT badge (task 1003737) ───────────────────────
|
|
488
|
+
//
|
|
489
|
+
// The owner's ask: show that sittings wait on your vote WITHOUT going inside.
|
|
490
|
+
// The count already rode /me (boardVotesNeed, pre-filtered to ballots this viewer
|
|
491
|
+
// could actually cast); what the hall had was no badge/bell/dot mechanism of ANY
|
|
492
|
+
// kind. These pin the MECHANISM, not the one consumer — the rules below are what
|
|
493
|
+
// a second adopter (help requests, the idea inbox) inherits for free.
|
|
494
|
+
|
|
495
|
+
const boardNeeds = (count, extra = {}) => ({
|
|
496
|
+
items: [{
|
|
497
|
+
id: 'board_votes', state: 'action_needed', count,
|
|
498
|
+
title: `${count} ideas are waiting on your vote`, ...extra,
|
|
499
|
+
}],
|
|
500
|
+
});
|
|
501
|
+
const seeingBoard = { authed: true, rank: 'xenos', permissions: { 'board.vote.cast': true } };
|
|
502
|
+
|
|
503
|
+
test('THE ASK: a waiting sitting puts a count on the Board Room item', () => {
|
|
504
|
+
const b = boot();
|
|
505
|
+
b.shell.applyAccess({ ...seeingBoard, needs: boardNeeds(3) });
|
|
506
|
+
assert.deepEqual(b.navBadge('board-room'), { text: '3', label: '3 ideas are waiting on your vote' });
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('ZERO IS SILENT — and so is every other kind of nothing', () => {
|
|
510
|
+
// Five different nothings, one answer. A reader must not be able to tell an
|
|
511
|
+
// empty board from a /me that has not landed: both mean "nothing to act on".
|
|
512
|
+
const cases = {
|
|
513
|
+
'no needs key at all': undefined,
|
|
514
|
+
'needs is null (not yet told)': null,
|
|
515
|
+
'a roster with no board need': { items: [{ id: 'art_key', state: 'action_needed' }] },
|
|
516
|
+
'the need exists but counts zero': boardNeeds(0),
|
|
517
|
+
'a need carrying no count at all': { items: [{ id: 'board_votes', state: 'action_needed' }] },
|
|
518
|
+
};
|
|
519
|
+
for (const [name, needs] of Object.entries(cases)) {
|
|
520
|
+
const b = boot();
|
|
521
|
+
b.shell.applyAccess({ ...seeingBoard, needs });
|
|
522
|
+
assert.equal(b.navBadge('board-room'), null, `${name} → no badge`);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('the founding grace silences the badge, because a badge is a demand', () => {
|
|
527
|
+
// computeNeeds rewrites every action_needed row to 'covered' during a new
|
|
528
|
+
// instance's first days, precisely so it does not greet its owner with demands.
|
|
529
|
+
// The count SURVIVES that rewrite (the grace spreads the row), so a badge keyed
|
|
530
|
+
// on the number alone would shout straight through the grace.
|
|
531
|
+
const b = boot();
|
|
532
|
+
b.shell.applyAccess({ ...seeingBoard, needs: boardNeeds(4, { state: 'covered' }) });
|
|
533
|
+
assert.equal(b.navBadge('board-room'), null, 'covered is not action_needed');
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('A BADGE NEVER OUT-RUNS ITS GATE — no count on a room you cannot see', () => {
|
|
537
|
+
// The disclosure rule. The count is real and the viewer holds no atom, so the
|
|
538
|
+
// item is hidden; the badge must not be written anyway. If it were, the DOM —
|
|
539
|
+
// and a screen reader walking it — would carry board activity to someone with
|
|
540
|
+
// no access to the room.
|
|
541
|
+
const b = boot();
|
|
542
|
+
b.shell.applyAccess({
|
|
543
|
+
authed: true, rank: 'archon', permissions: { 'board.vote.cast': false }, needs: boardNeeds(7),
|
|
544
|
+
});
|
|
545
|
+
assert.equal(b.navHidden('board-room'), true, 'precondition: the gate hides it');
|
|
546
|
+
assert.equal(b.navBadge('board-room'), null, 'a hidden item carries no count');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test('a badge already shown is CLEARED when access goes away', () => {
|
|
550
|
+
// The stale-count case: skipping hidden items rather than clearing them would
|
|
551
|
+
// leave the previous state's number sitting in the DOM after a sign-out.
|
|
552
|
+
const b = boot();
|
|
553
|
+
b.shell.applyAccess({ ...seeingBoard, needs: boardNeeds(2) });
|
|
554
|
+
assert.ok(b.navBadge('board-room'), 'precondition: it showed');
|
|
555
|
+
b.shell.applyAccess({ authed: false });
|
|
556
|
+
assert.equal(b.navBadge('board-room'), null, 'signing out takes the count with it');
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test('a big count is capped rather than allowed to stretch the rail', () => {
|
|
560
|
+
const b = boot();
|
|
561
|
+
b.shell.applyAccess({ ...seeingBoard, needs: boardNeeds(140) });
|
|
562
|
+
assert.equal(b.navBadge('board-room').text, '99+');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test('the count reaches a screen reader as WORDS, not a floating numeral', () => {
|
|
566
|
+
// The numeral is aria-hidden decoration; the need's own sentence rides beside
|
|
567
|
+
// it in an sr-only span. "3" alone inside a link named Board Room says nothing.
|
|
568
|
+
const html = boot().sidebarHtml;
|
|
569
|
+
const item = html.split('<a class="nav-item"').find((t) => t.includes('id="nav-board-room"'));
|
|
570
|
+
assert.ok(item, 'the Board Room item renders');
|
|
571
|
+
assert.match(item, /class="nav-item__badge" data-badge aria-hidden="true" hidden/, 'the numeral is decoration');
|
|
572
|
+
assert.match(item, /class="sr-only" data-badge-label/, 'the words are what a reader gets');
|
|
573
|
+
// An item that declared no count grows neither node — the badge is opt-in.
|
|
574
|
+
const home = html.split('<a class="nav-item"').find((t) => t.includes('id="nav-home"'));
|
|
575
|
+
assert.ok(!/data-badge/.test(home), 'no count declared, no badge markup');
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test('the mechanism is GENERIC — it keys on a declared need id, not on the board', () => {
|
|
579
|
+
// What makes a second consumer cheap: shell.js names a need id in `count:` and
|
|
580
|
+
// reads that row's number. Nothing in the badge path knows what a board IS, so
|
|
581
|
+
// adopting it is one field here plus one field on the need.
|
|
582
|
+
const fn = SRC.slice(SRC.indexOf('function applyCounts'), SRC.indexOf('// applyAccess({'));
|
|
583
|
+
assert.ok(fn.length > 0, 'applyCounts exists');
|
|
584
|
+
assert.ok(!/board/i.test(fn), 'the badge resolver names no specific need');
|
|
585
|
+
assert.match(fn, /dataset\.count/, 'it keys on the item’s own declared need id');
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('the badge is styled for BOTH shapes of the rail', () => {
|
|
589
|
+
// The rail is a 64px icon strip until hover/focus opens it, and the drawer is
|
|
590
|
+
// it permanently open with no hover to trigger anything. A badge positioned for
|
|
591
|
+
// one of the three reads wrong in the others, and no unit test would catch it.
|
|
592
|
+
const css = fs.readFileSync(path.join(HALL, 'style.css'), 'utf8');
|
|
593
|
+
assert.match(css, /\.nav-item__badge \{/, 'the collapsed (icon-only) position');
|
|
594
|
+
assert.match(css, /:focus-within \.nav-item__badge/, 'the open-rail position');
|
|
595
|
+
const drawer = css.slice(css.indexOf('html.drawer-open .app-sidebar'));
|
|
596
|
+
assert.match(drawer, /\.nav-item__badge \{ top: 50%/, 'the mobile drawer position');
|
|
597
|
+
});
|
package/tests/rot_inbox.mjs
CHANGED
|
@@ -309,13 +309,89 @@ test('an unattributed thing is visible ONLY to canSeeAll — it has no creator t
|
|
|
309
309
|
'a NULL creator must not be OR-ed into every builder\'s inbox');
|
|
310
310
|
});
|
|
311
311
|
|
|
312
|
-
test('each row carries the verbs for ITS tier, so the client never hardcodes the mapping', async () => {
|
|
313
|
-
expect({ rows: [rotRow(), rotRow({ tier: 'goal', id: '9' })] });
|
|
312
|
+
test('each row carries the verbs for ITS tier AND ITS status, so the client never hardcodes the mapping', async () => {
|
|
313
|
+
expect({ rows: [rotRow(), rotRow({ tier: 'goal', id: '9', status: 'open' })] });
|
|
314
314
|
const got = await rot.rottingFor({ builderId: 77, rotDays: 30 });
|
|
315
|
-
|
|
315
|
+
// rotRow() is a BACKLOG task: promote accepts it, demote does not.
|
|
316
|
+
assert.deepEqual(got.rows[0].verbs.map((v) => v.verb), ['prioritise', 'kill', 'water']);
|
|
316
317
|
assert.deepEqual(got.rows[1].verbs.map((v) => v.verb), ['kill', 'water']);
|
|
317
318
|
});
|
|
318
319
|
|
|
320
|
+
// ---- 4b. the offer must be the row's, not the tier's (task 1003751) ---------
|
|
321
|
+
//
|
|
322
|
+
// The regression this replaces: verbs were attached as `tier === 'task' ? TASK_VERBS
|
|
323
|
+
// : GOAL_VERBS`, by tier only. Since TASK_ROTTABLE_STATUSES is exactly
|
|
324
|
+
// ['backlog','ready'] and promote/demote accept disjoint sets, EVERY task line in
|
|
325
|
+
// the feed shipped one button whose route answers 409 — a backlog row offered
|
|
326
|
+
// Prune, a ready row offered Prioritise. The card's own header promises the
|
|
327
|
+
// opposite ("the client renders what the server will actually accept").
|
|
328
|
+
|
|
329
|
+
test('a backlog row is not offered Prune, and a ready row is not offered Prioritise', async () => {
|
|
330
|
+
expect({ rows: [rotRow({ status: 'backlog' }), rotRow({ id: '6', status: 'ready' })] });
|
|
331
|
+
const got = await rot.rottingFor({ builderId: 77, rotDays: 30 });
|
|
332
|
+
const backlog = got.rows[0].verbs.map((v) => v.verb);
|
|
333
|
+
const ready = got.rows[1].verbs.map((v) => v.verb);
|
|
334
|
+
assert.ok(!backlog.includes('prune'), "a backlog task is already at the bottom — /demote answers 409 cannot_demote");
|
|
335
|
+
assert.ok(backlog.includes('prioritise'), 'a backlog task is exactly what /promote is for');
|
|
336
|
+
assert.ok(!ready.includes('prioritise'), "a ready task is already claimable — /promote answers 409 cannot_promote");
|
|
337
|
+
assert.ok(ready.includes('prune'), 'ready is the only status /demote accepts');
|
|
338
|
+
// kill and water are unconditional across the rottable set, so no row loses them.
|
|
339
|
+
for (const offered of [backlog, ready]) {
|
|
340
|
+
assert.ok(offered.includes('kill') && offered.includes('water'),
|
|
341
|
+
'kill and water apply to every rottable row — narrowing must not drop them');
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('no verb is offered for a status its route would refuse, across the whole rottable set', () => {
|
|
346
|
+
// The end-to-end claim of this feature, checked against the route guards rather
|
|
347
|
+
// than against a second hardcoded list. Every status that can reach the feed,
|
|
348
|
+
// every verb attached to it, must name a route that accepts that status.
|
|
349
|
+
for (const status of rot.TASK_ROTTABLE_STATUSES) {
|
|
350
|
+
const offered = rot.verbsFor('task', status).map((v) => v.verb);
|
|
351
|
+
assert.ok(offered.length > 0, `status '${status}' was offered no verbs at all`);
|
|
352
|
+
for (const verb of offered) {
|
|
353
|
+
const accepts = rot.TASK_VERB_ACCEPTS[verb];
|
|
354
|
+
assert.ok(!accepts || accepts.includes(status),
|
|
355
|
+
`verb '${verb}' is offered on a '${status}' row but its route accepts only [${accepts}] — that button 409s`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('the accepted-status map is the one the ROUTES actually enforce', () => {
|
|
361
|
+
// The unavoidable-copy pairing guard, in the same spirit as the rot-clock check
|
|
362
|
+
// against migration core_231: rot.js must mirror routes/tasks.js, so read the
|
|
363
|
+
// guards back out of the route source and fail if they have drifted apart.
|
|
364
|
+
const TASKS_SRC = readFileSync(join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
|
|
365
|
+
|
|
366
|
+
const promoteGuard = /if \(!\[([^\]]*)\]\.includes\(task\.status\)\)\s*\{\s*return res\.fail\('cannot_promote'/.exec(TASKS_SRC);
|
|
367
|
+
assert.ok(promoteGuard, "could not find /promote's status guard in routes/tasks.js — if it was reshaped, re-derive TASK_VERB_ACCEPTS.prioritise from the new shape");
|
|
368
|
+
const promoteAccepts = promoteGuard[1].split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
|
|
369
|
+
assert.deepEqual(
|
|
370
|
+
[...rot.TASK_VERB_ACCEPTS.prioritise].sort(), promoteAccepts.sort(),
|
|
371
|
+
'TASK_VERB_ACCEPTS.prioritise no longer matches what POST /tasks/:id/promote accepts — the rot card would offer (or withhold) a Prioritise button wrongly'
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const demoteGuard = /if \(task\.status !== '([^']+)'\)\s*\{\s*return res\.fail\('cannot_demote'/.exec(TASKS_SRC);
|
|
375
|
+
assert.ok(demoteGuard, "could not find /demote's status guard in routes/tasks.js — if it was reshaped, re-derive TASK_VERB_ACCEPTS.prune from the new shape");
|
|
376
|
+
assert.deepEqual(
|
|
377
|
+
[...rot.TASK_VERB_ACCEPTS.prune], [demoteGuard[1]],
|
|
378
|
+
'TASK_VERB_ACCEPTS.prune no longer matches what POST /tasks/:id/demote accepts'
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('every key in the accepted-status map is a real task verb', () => {
|
|
383
|
+
// A typo'd key would silently make its verb UNCONDITIONAL (absent = no filter),
|
|
384
|
+
// which is the exact bug this map exists to fix, reintroduced quietly.
|
|
385
|
+
const known = new Set(rot.TASK_VERBS.map((v) => v.verb));
|
|
386
|
+
for (const verb of Object.keys(rot.TASK_VERB_ACCEPTS)) {
|
|
387
|
+
assert.ok(known.has(verb), `'${verb}' is not a declared task verb — a stale key filters nothing`);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('a goal row keeps both its verbs — neither goal route is status-conditional here', () => {
|
|
392
|
+
assert.deepEqual(rot.verbsFor('goal', 'open').map((v) => v.verb), ['kill', 'water']);
|
|
393
|
+
});
|
|
394
|
+
|
|
319
395
|
// ---- 5. the timer resolver: precedence + the reporting fail posture ---------
|
|
320
396
|
|
|
321
397
|
test('no row anywhere = the documented default, and it says so', async () => {
|