@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.
@@ -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
+ });
@@ -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) { return sel === '#app-sidebar [data-gate]' ? gatedNodes() : []; },
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
+ });
@@ -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
- assert.deepEqual(got.rows[0].verbs.map((v) => v.verb), ['prioritise', 'prune', 'kill', 'water']);
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 () => {