@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.
@@ -1731,5 +1731,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1731
1731
  landed since 1.19.639 with no explicit bump. run 34506041899. (task 1002620)
1732
1732
  1.19.641 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1733
1733
  landed since 1.19.640 with no explicit bump. run 34506718627. (task 1002620)
1734
+ 1.19.642 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1735
+ landed since 1.19.641 with no explicit bump. run 34507442448. (task 1002620)
1736
+ 1.19.643 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1737
+ landed since 1.19.642 with no explicit bump. run 34509123612. (task 1002620)
1734
1738
  ---------------------------------------------------------------------------
1735
1739
  ```
@@ -121,6 +121,14 @@ function helpRequestsNeed(ctx) {
121
121
 
122
122
  // The registry. To add a future input the system needs from a builder, append a
123
123
  // pure (ctx) => need|null function here — every renderer picks it up for free.
124
+ //
125
+ // A need MAY carry `count: <positive integer>` (task 1003737) when the thing
126
+ // waiting is countable. That is what a nav badge renders, and it is the whole
127
+ // contract: the shell names a need id and reads this number, so a second
128
+ // consumer — open help requests, the idea inbox — adopts the badge by adding
129
+ // this one field rather than inventing a second notification mechanism.
130
+ // The count is ADVISORY DECORATION, never authority: the roster's own ordering
131
+ // (NEED_PRIORITY) and each surface's gate decide what a builder actually sees.
124
132
  // Open task RECOMMENDATIONS waiting on this builder (task 1003173 / C03, goal
125
133
  // 1000075; criterion collab-pass-a-task). Sibling of helpRequestsNeed above, and
126
134
  // here for the same reason C01's grader round put that one here: a recommendation
@@ -184,6 +192,12 @@ function boardVotesNeed(ctx) {
184
192
  const subject = n === 1 ? 'An idea is' : `${n} ideas are`;
185
193
  return {
186
194
  id: 'board_votes',
195
+ // The number, machine-readable (task 1003737). The message has always
196
+ // carried it in prose, which a sentence can render and a BADGE cannot. A
197
+ // need that is inherently countable states its count here; a need that is
198
+ // not (art_key — a key is set or it is not) simply omits it, and every
199
+ // renderer treats an absent count as "nothing to put in a badge".
200
+ count: n,
187
201
  title: n === 1 ? 'An idea is waiting on your vote' : 'Ideas are waiting on your vote',
188
202
  what: 'You sit on the board, and a Full Idea that cleared the completeness grade needs a decision.',
189
203
  state: 'action_needed',
@@ -216,6 +216,11 @@
216
216
  // the same reason: without it the index — the hall's busiest page — is the
217
217
  // one page a permission-gated nav item would never appear on.
218
218
  permissions: (ctx.lastMeState && ctx.lastMeState.permissions) || null,
219
+ // task 1003737: the needs roster, for the nav counts. Read off
220
+ // ctx.lastMeState for the same reason the two above are, and passed for the
221
+ // same reason: the index is where a builder lands, so omitting it here
222
+ // would mean the badge appeared everywhere EXCEPT the page they open first.
223
+ needs: (ctx.lastMeState && ctx.lastMeState.needs) || null,
219
224
  builder,
220
225
  });
221
226
 
@@ -129,6 +129,23 @@
129
129
  // stays hidden — deliberately, since an unresolvable gate is a gate
130
130
  // that cannot be vouched for.
131
131
  // `view` marks an index view (hash-routed); `module` gates on window.__MODULES__.
132
+ //
133
+ // `count` (task 1003737) names a NEED ID in the `needs` roster `/me` already
134
+ // returns; the item then carries a badge with that need's `count`. It is the
135
+ // hall's only badge mechanism and it is deliberately generic — a second
136
+ // consumer (open help requests, the idea inbox) adds `count: '<need id>'` here
137
+ // and a `count` field to its need, and inherits the rest.
138
+ //
139
+ // THREE RULES THE MECHANISM ENFORCES, not the caller:
140
+ // ZERO IS SILENT — no badge, no empty ring. A badge means something is
141
+ // genuinely waiting, so an absent need, a zero/absent count, or a need the
142
+ // founding grace has quieted out of 'action_needed' all render nothing.
143
+ // IT NEVER OUT-RUNS THE GATE — the badge is written only onto an item this
144
+ // viewer can already see, and cleared from one they cannot, so a count can
145
+ // never disclose activity in a room that is hidden from them.
146
+ // IT SPEAKS — the numeral is aria-hidden and the need's own `title` rides
147
+ // alongside it as sr-only text, because "3" alone in a link tells a screen
148
+ // reader nothing about what is waiting.
132
149
  // task 2175: grouped by AUDIENCE of the data, so the sidebar reads in coherent
133
150
  // runs instead of one long list — the project (unlabeled), You, Community,
134
151
  // Docs, Government. A group's `gate` must match its most permissive item's, or the
@@ -168,7 +185,12 @@
168
185
  // are unchanged.
169
186
  //
170
187
  // 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' },
188
+ // task 1003737: the badge's first consumer. The owner's ask was to see that
189
+ // a sitting waits WITHOUT opening the room, and the count is already on
190
+ // /me (boardVotesNeed), pre-filtered to what the vote route would accept —
191
+ // so the badge counts ballots this viewer can actually cast, never "items
192
+ // open somewhere".
193
+ { id: 'board-room', label: 'Board Room', icon: 'admin', href: '/board-room', gate: 'perm:board.vote.cast', count: 'board_votes' },
172
194
  // task 1003113 (R02 of goal 1000074): the artist's queue over the words the
173
195
  // product shows people. gate 'signed' and NOT a craft gate — filing a flag
174
196
  // is open to every role on purpose (the engineer who wrote the toast and
@@ -344,8 +366,24 @@
344
366
  (it.external ? ' target="_blank" rel="noopener"' : '') +
345
367
  (it.view ? ' data-view="' + it.view + '"' : '') +
346
368
  ' data-gate="' + it.gate + '"' + (it.module ? ' data-module="' + it.module + '"' : '') +
369
+ (it.count ? ' data-count="' + esc(it.count) + '"' : '') +
347
370
  ' title="' + esc(it.label) + '" hidden>' + icon(it.icon) +
348
- '<span class="nav-item__label">' + esc(it.label) + '</span></a>';
371
+ '<span class="nav-item__label">' + esc(it.label) + '</span>' +
372
+ // task 1003737: the badge ships EMPTY and hidden, and applyCounts fills
373
+ // it. Rendered here rather than injected later so the anchor's shape is
374
+ // fixed at first paint — a badge appended after the /me round-trip
375
+ // would reflow the rail under the reader's cursor.
376
+ //
377
+ // Two nodes, because they address two audiences: the numeral is
378
+ // aria-hidden decoration for the eye, and the sr-only span carries the
379
+ // need's own sentence for a screen reader. One node cannot do both —
380
+ // "3" read aloud inside a link named Board Room says nothing about what
381
+ // the 3 is.
382
+ (it.count
383
+ ? '<span class="nav-item__badge" data-badge aria-hidden="true" hidden></span>'
384
+ + '<span class="sr-only" data-badge-label></span>'
385
+ : '') +
386
+ '</a>';
349
387
  }).join('');
350
388
  // task 1003303: aria-controls names the region the head discloses. The
351
389
  // frozen markup carried aria-expanded pointing at nothing, which is an
@@ -559,7 +597,7 @@
559
597
  // not establish authority. null is NOT "holds nothing that matters": it is
560
598
  // unreadable, and unreadable hides the link, the same call gateAllows()'s
561
599
  // `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 };
600
+ var access = { authed: null, rank: null, graduated: false, showWork: false, permissions: null, needs: null };
563
601
 
564
602
  function gateAllows(gate) {
565
603
  var authed = access.authed === true;
@@ -626,8 +664,47 @@
626
664
  el.classList.toggle('nav-item--craft', isArtist);
627
665
  }
628
666
 
629
- // applyAccess({ authed, rank, graduated, showWork, social }) settle nav
630
- // visibility and the social mode.
667
+ // task 1003737: the nav badge mechanism. Reads the `needs` roster `/me`
668
+ // already returns and writes the count onto every item that declared one.
669
+ //
670
+ // WHY IT RUNS INSIDE applyAccess AND AFTER THE GATE LOOP, not beside it: the
671
+ // gate loop is the thing that decides whether this viewer may see the item at
672
+ // all, so reading `el.hidden` afterwards is reading the answer rather than
673
+ // re-deriving it — the same discipline task 1003744 applied to `permissions`
674
+ // (a surface that decides for itself can disagree with the rest of the hall).
675
+ // A hidden item is CLEARED rather than skipped: skipping would leave a stale
676
+ // count in the DOM of someone who just lost access to the room.
677
+ //
678
+ // Silence has four causes and they are deliberately indistinguishable to the
679
+ // reader: no needs payload yet (the /me round-trip is still out), no such need,
680
+ // a need the founding grace has moved out of 'action_needed', or a count of
681
+ // zero. In every one of them the honest badge is no badge.
682
+ function applyCounts() {
683
+ var needs = (access.needs && Array.isArray(access.needs.items)) ? access.needs.items : [];
684
+ document.querySelectorAll('#app-sidebar .nav-item[data-count]').forEach(function (el) {
685
+ var badge = el.querySelector('[data-badge]');
686
+ var label = el.querySelector('[data-badge-label]');
687
+ if (!badge || !label) return;
688
+ var need = null;
689
+ for (var i = 0; i < needs.length; i++) {
690
+ if (needs[i] && needs[i].id === el.dataset.count) { need = needs[i]; break; }
691
+ }
692
+ // 'action_needed' is the gate on LOUDNESS: the founding grace rewrites a
693
+ // demanding need to 'covered' precisely so a newborn instance does not
694
+ // greet its owner with demands, and a badge is a demand.
695
+ var n = (need && need.state === 'action_needed') ? Number(need.count) : 0;
696
+ var show = !el.hidden && Number.isFinite(n) && n > 0;
697
+ badge.hidden = !show;
698
+ badge.textContent = show ? (n > 99 ? '99+' : String(n)) : '';
699
+ // The need's own title, which is already a sentence written for a person
700
+ // ("Ideas are waiting on your vote"). Falling back to a bare count would
701
+ // reintroduce exactly the floating numeral this avoids.
702
+ label.textContent = show ? (need.title || (n + ' waiting')) : '';
703
+ });
704
+ }
705
+
706
+ // applyAccess({ authed, rank, graduated, showWork, social, needs }) — settle nav
707
+ // visibility, the social mode, and the nav counts.
631
708
  // Called by builders.js on the index (it owns /me there) and by the shell's own
632
709
  // probe elsewhere. Cosmetic only; the server enforces (ADR 0016).
633
710
  function applyAccess(state) {
@@ -643,6 +720,11 @@
643
720
  if ('permissions' in state) {
644
721
  access.permissions = (state.permissions && typeof state.permissions === 'object') ? state.permissions : null;
645
722
  }
723
+ // task 1003737: the needs roster, for the nav counts. Stored as given —
724
+ // null means "the shell has not been told yet", which reads as no badge.
725
+ if ('needs' in state) {
726
+ access.needs = (state.needs && typeof state.needs === 'object') ? state.needs : null;
727
+ }
646
728
  if ('social' in state) applySocial(state.social);
647
729
  if (state.builder) { renderUser(state.builder); applyCraftEmphasis(state.builder); }
648
730
  }
@@ -651,6 +733,10 @@
651
733
  if (ok && el.dataset.module && !moduleOn(el.dataset.module)) ok = false;
652
734
  el.hidden = !ok;
653
735
  });
736
+ // AFTER the gate loop: applyCounts reads el.hidden, which the loop above is
737
+ // what settles. Reversing the order would badge an item still hidden from
738
+ // the previous state.
739
+ applyCounts();
654
740
  var signin = document.getElementById('shell-signin');
655
741
  if (signin) signin.hidden = access.authed !== false;
656
742
  var user = document.getElementById('shell-user');
@@ -862,6 +948,10 @@
862
948
  social: me.social || null,
863
949
  // task 1003744: the resolved permission map, for the `perm:` nav gates.
864
950
  permissions: me.permissions || null,
951
+ // task 1003737: the needs roster, for the nav counts. Same payload the
952
+ // hall's Standing card and the CLI nudge read — no new route, no extra
953
+ // poll, and the badge cannot disagree with the roster it came from.
954
+ needs: me.needs || null,
865
955
  builder: me.builder,
866
956
  });
867
957
  }).catch(function () { /* cosmetic only */ });
@@ -667,6 +667,40 @@ main:focus { outline: none; }
667
667
  .nav-item--craft .nav-icon,
668
668
  .nav-item--craft .nav-item__label { color: var(--accent-ink); }
669
669
 
670
+ /* task 1003737: the nav count badge — the hall's only badge mechanism, built
671
+ once and generically (shell.js `count:` names a need id; see the NAV model).
672
+ ABSOLUTE, not inline, because the rail has two shapes: it is a 64px icon strip
673
+ until hover/focus opens it, and the badge has to read in BOTH — the whole ask
674
+ was to see that a sitting waits WITHOUT opening anything. Anchoring it to the
675
+ icon's top-right corner is the one position that survives the label leaving
676
+ and re-entering the layout, so nothing reflows when the rail opens.
677
+ Accent because the world's grammar reserves accent for the one live thing
678
+ (the same rule --active and --craft follow); shell.js keeps it off entirely
679
+ when the count is zero, so an accent here always means something waits. */
680
+ .nav-item__badge {
681
+ position: absolute;
682
+ top: 3px;
683
+ right: 3px;
684
+ display: flex;
685
+ align-items: center;
686
+ justify-content: center;
687
+ min-width: 17px;
688
+ height: 17px;
689
+ padding: 0 5px;
690
+ border-radius: var(--radius-pill);
691
+ background: var(--accent);
692
+ color: var(--accent-on);
693
+ font-size: 11px;
694
+ font-weight: 600;
695
+ font-variant-numeric: tabular-nums;
696
+ line-height: 1;
697
+ pointer-events: none;
698
+ }
699
+ /* Open rail: the item grows a 12px gutter and left-aligns, so the badge follows
700
+ the right edge rather than sitting over the label's tail. */
701
+ .app-sidebar:hover .nav-item__badge,
702
+ .app-sidebar:focus-within .nav-item__badge { top: 50%; right: 12px; transform: translateY(-50%); }
703
+
670
704
  .app-sidebar__foot {
671
705
  padding: 10px 0;
672
706
  border-top: 1px solid var(--glass-edge);
@@ -1015,6 +1049,10 @@ main:focus { outline: none; }
1015
1049
  .nav-group--collapsible .nav-group__head { display: flex; }
1016
1050
  .app-sidebar__brand { justify-content: flex-start; padding: 0 22px; }
1017
1051
  .nav-item, .shell-home, .shell-signout { justify-content: flex-start; padding: 0 12px; }
1052
+ /* task 1003737: the drawer IS the rail already open, and there is no hover on
1053
+ a phone to trigger the open-rail rule — so the badge takes the open position
1054
+ here unconditionally, or it would sit over the label on every mobile view. */
1055
+ .nav-item__badge { top: 50%; right: 12px; transform: translateY(-50%); }
1018
1056
  .app-sidebar__foot { align-items: stretch; padding: 12px 16px; }
1019
1057
  .shell-user { justify-content: flex-start; }
1020
1058
  .topbar-menu { display: flex; }
@@ -80,6 +80,54 @@ const GOAL_VERBS = Object.freeze([
80
80
  Object.freeze({ verb: 'water', label: 'Water', method: 'POST', path: '/goals/:id/water', hint: 'leave it — reset the clock' }),
81
81
  ]);
82
82
 
83
+ // WHICH of those verbs a given row may actually be offered — the half the list
84
+ // above cannot express. A verb list attached by TIER alone is a list of what the
85
+ // tier CAN do, not of what THIS row can do, and the two differ: promote and demote
86
+ // are each valid for a disjoint set of statuses, so on every row in this feed one
87
+ // of the two is a button whose route answers 409. That is exactly the drift the
88
+ // note above says the design prevents ("the client renders what the server will
89
+ // actually accept"); the intent was right and the implementation ignored status
90
+ // (task 1003751).
91
+ //
92
+ // Mirrored from the route guards, which are the authority:
93
+ // prioritise -> POST /tasks/:id/promote refuses anything but backlog|blocked|
94
+ // abandoned ('cannot_promote', modules/lifecycle/routes/tasks.js)
95
+ // prune -> POST /tasks/:id/demote refuses anything but ready
96
+ // ('cannot_demote', same file)
97
+ // A verb ABSENT from this map is unconditional across the rottable set, and each
98
+ // absence is a fact about its route rather than an omission:
99
+ // kill -> /tasks/:id/abandon refuses only a shipped task or one holding an
100
+ // active claim. Neither can appear here: 'shipped' is not rottable, and
101
+ // a claimed task is 'active', which is not rottable either.
102
+ // water -> filters on TASK_ROTTABLE_STATUSES itself, which IS this feed's set.
103
+ // Because the copy is unavoidable, tests/rot_inbox.mjs reads the accepted statuses
104
+ // back out of the route source and fails if the two ever disagree — the same
105
+ // pairing guard the rot clock gets against migration core_231.
106
+ //
107
+ // A SECOND copy of this decision lives in scripts/gds/backlog-review.js
108
+ // (`verbsFor`, task 1003746), deliberately: that script is an HTTP client that must
109
+ // run from a scoped checkout without loading the server's module tree, so it cannot
110
+ // require this file. The route-guard pairing test below is what keeps both honest.
111
+ const TASK_VERB_ACCEPTS = Object.freeze({
112
+ prioritise: Object.freeze(['backlog', 'blocked', 'abandoned']),
113
+ prune: Object.freeze(['ready']),
114
+ });
115
+
116
+ // verbsFor(tier, status) — the verbs to attach to ONE row: the tier's catalogue
117
+ // narrowed to what this row's status will actually be accepted for.
118
+ //
119
+ // A goal gets its two unconditionally: /goals/:id/archive refuses only an ALREADY
120
+ // archived goal, and /goals/:id/water filters on the rottable set — so for a goal
121
+ // in this feed (status 'open') both always apply.
122
+ function verbsFor(tier, status) {
123
+ if (tier === 'goal') return GOAL_VERBS;
124
+ const s = String(status);
125
+ return Object.freeze(TASK_VERBS.filter((v) => {
126
+ const accepts = TASK_VERB_ACCEPTS[v.verb];
127
+ return !accepts || accepts.includes(s);
128
+ }));
129
+ }
130
+
83
131
  const MAX_ROT_ROWS = 200;
84
132
 
85
133
  // rottingFor({ builderId, canSeeAll, rotDays, limit }) — the rot lines this caller
@@ -146,7 +194,7 @@ async function rottingFor({ builderId, canSeeAll = false, rotDays, limit = 50 }
146
194
 
147
195
  const cutoffAt = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
148
196
  return {
149
- rows: rows.map((r) => ({ ...r, verbs: r.tier === 'task' ? TASK_VERBS : GOAL_VERBS })),
197
+ rows: rows.map((r) => ({ ...r, verbs: verbsFor(r.tier, r.status) })),
150
198
  rotDays: days,
151
199
  cutoff: cutoffAt,
152
200
  };
@@ -203,6 +251,8 @@ module.exports = {
203
251
  GOAL_ROT_CLOCK,
204
252
  TASK_VERBS,
205
253
  GOAL_VERBS,
254
+ TASK_VERB_ACCEPTS,
255
+ verbsFor,
206
256
  MAX_ROT_ROWS,
207
257
  rottingFor,
208
258
  water,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.641",
3
+ "version": "1.19.643",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.641",
9
+ "version": "1.19.643",
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.641",
3
+ "version": "1.19.643",
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",
@@ -320,7 +320,7 @@ function transformSettings(srcPath) {
320
320
  const HOOK_TEXT_EXT = new Set(['.js', '.mjs', '.cjs', '.json', '.sh', '.md', '.txt']); // hooks are .js — rebrand them too
321
321
 
322
322
  function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite = true, log = () => {} }) {
323
- const summary = { skipped: false, skills: 0, moduleSkills: 0, settings: false, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], rebrandRules: 0, claudeMd: false, notes: [] };
323
+ const summary = { skipped: false, skills: 0, moduleSkills: 0, settings: false, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], withdrawnSkills: [], rebrandRules: 0, claudeMd: false, notes: [] };
324
324
  if (path.resolve(instanceDir) === path.resolve(coreRoot)) {
325
325
  summary.skipped = true;
326
326
  summary.notes.push('instance dir is the core root — nothing to materialize (byte-identical no-op).');
@@ -351,7 +351,11 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
351
351
  // carries them as untracked copies plus two dotfiles; step 1 must not ship those as core
352
352
  // skills — step 1b lands them from the module, transformed, like everywhere else.
353
353
  const materialisedInCore = readModuleSkillsManifest(path.join(coreRoot, '.claude', 'skills')).skills;
354
- const skillSkip = (rel) => { const top = String(rel).split(/[\\/]/)[0]; return skipSkills.has(top) || top === MODULE_SKILLS_MANIFEST || top === '.gitignore' || Object.prototype.hasOwnProperty.call(materialisedInCore, top); };
354
+ // The INSTANCE's own provenance record what a PREVIOUS materialize landed here.
355
+ // Distinct from materialisedInCore above (that one is the core checkout's). Step 1c
356
+ // withdraws from this set and nothing else.
357
+ const landedPreviously = readInstanceSkillsManifest(path.join(destClaude, 'skills')).skills;
358
+ const skillSkip = (rel) => { const top = String(rel).split(/[\\/]/)[0]; return skipSkills.has(top) || top === MODULE_SKILLS_MANIFEST || top === INSTANCE_SKILLS_MANIFEST || top === '.gitignore' || Object.prototype.hasOwnProperty.call(materialisedInCore, top); };
355
359
 
356
360
  // 1. skills — ship in the core package; copy, rewrite invocations→bongos, rebrand
357
361
  // identity, and exclude any skill owned by a disabled module.
@@ -390,6 +394,67 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
390
394
  }
391
395
  if (summary.moduleSkills) log(` ${dryRun ? '[dry-run] would materialize' : '✓ materialized'} ${summary.moduleSkills} module-owned skill file(s) → .claude/skills/ (from modules/<key>/skills/)`);
392
396
 
397
+ // 1c. WITHDRAW a skill a previous materialize landed here that this run no longer
398
+ // lands — the module was switched off (or stopped declaring the name) on an
399
+ // instance that ALREADY has the files. Step 1's skillSkip and step 1b's
400
+ // `enabled` check both only decline to COPY, which is the whole bug: disabling
401
+ // a module was honoured on a fresh instance and silently ignored on
402
+ // `bongos upgrade`, leaving stale dirs in .claude/skills/ for the harness to
403
+ // keep loading (task 1003632, found by the on/off proof of task 1003331).
404
+ // materializeModuleSkillsIntoCore has done this for the core checkout since
405
+ // task 1003330; this is the same mechanism for the instance side.
406
+ //
407
+ // PROVENANCE-GATED, and that gate is the safety property: only a name the
408
+ // manifest says a previous run LANDED is eligible. A skill the instance's owner
409
+ // wrote by hand was never in the manifest, so it can never be deleted here even
410
+ // if a module happens to declare the same name. With no manifest at all — an
411
+ // instance last materialized before this shipped — nothing is withdrawn and a
412
+ // note says so: that first run writes the provenance, and every run after it
413
+ // honours a disable. Refusing to guess is the point; deleting a builder's skill
414
+ // because we cannot prove we put it there is the one outcome worth avoiding.
415
+ const skillsDest = path.join(destClaude, 'skills');
416
+ const landedNow = {};
417
+ for (const src of sources) {
418
+ if (!src.enabled) continue;
419
+ // A dry run copied nothing, so it records what WOULD land; a real run confirms
420
+ // the dir is actually there before claiming provenance over it.
421
+ if (!dryRun && !fs.existsSync(path.join(skillsDest, src.name))) continue;
422
+ landedNow[src.name] = { module: src.module, source: path.relative(coreRoot, src.dir).split(path.sep).join('/'), vendored: src.vendored };
423
+ }
424
+ // A core-SHIPPED skill whose name a module also declares is landed by step 1, not
425
+ // step 1b — but it is withdrawable by the same disabled-module rule, so it needs
426
+ // provenance too.
427
+ for (const mod of roster) {
428
+ if (!mod.enabled) continue;
429
+ for (const name of mod.skills) {
430
+ if (landedNow[name] || !fs.existsSync(path.join(skillsSrc, name))) continue;
431
+ landedNow[name] = { module: mod.key, source: `.claude/skills/${name}`, coreShipped: true };
432
+ }
433
+ }
434
+ const withdrawable = Object.keys(landedPreviously).filter((n) => SKILL_NAME_RE.test(n) && !landedNow[n]);
435
+ if (!overwrite) {
436
+ // adopt mode never clobbers what the owner already has; withdrawing is a clobber.
437
+ if (withdrawable.length) summary.notes.push(`${withdrawable.length} skill(s) of a now-disabled module were left in place (adopt mode does not withdraw): ${withdrawable.sort().join(', ')}.`);
438
+ } else {
439
+ for (const name of withdrawable.sort()) {
440
+ const dir = path.join(skillsDest, name);
441
+ if (!fs.existsSync(dir)) continue;
442
+ if (!dryRun) fs.rmSync(dir, { recursive: true, force: true });
443
+ summary.withdrawnSkills.push(name);
444
+ }
445
+ if (summary.withdrawnSkills.length) log(` ${dryRun ? '[dry-run] would withdraw' : '✓ withdrew'} ${summary.withdrawnSkills.length} stale skill dir(s) whose module is no longer enabled: ${summary.withdrawnSkills.join(', ')}`);
446
+ }
447
+ if (!Object.keys(landedPreviously).length && summary.excludedSkills.length) {
448
+ summary.notes.push('no .instance-skills.json provenance in .claude/skills/ yet, so nothing was withdrawn — this run records it; a later run can withdraw the skills of a module that has since been disabled.');
449
+ }
450
+ // Record what landed, so the NEXT run can withdraw what this one placed.
451
+ if (!dryRun && overwrite) {
452
+ const names = Object.keys(landedNow).sort();
453
+ const manifest = { _doc: 'GENERATED by scripts/gds/claude-materialize.js (task 1003632): the module-declared skills a materialize landed in this instance. It is the provenance record that lets a later run WITHDRAW a skill whose module has since been disabled — a name absent from here is never deleted, so a hand-written skill is safe. Commit it; do not hand-edit.', skills: Object.fromEntries(names.map((n) => [n, landedNow[n]])) };
454
+ fs.mkdirSync(skillsDest, { recursive: true });
455
+ fs.writeFileSync(path.join(skillsDest, INSTANCE_SKILLS_MANIFEST), JSON.stringify(manifest, null, 2) + '\n');
456
+ }
457
+
393
458
  // 2. settings.json — topology; present in the checkout/core repo, absent in a package-only install.
394
459
  const settingsSrc = path.join(srcClaude, 'settings.json');
395
460
  if (fs.existsSync(settingsSrc)) {
@@ -494,10 +559,23 @@ function isCoreCheckoutRoot(root) {
494
559
  }
495
560
 
496
561
  const MODULE_SKILLS_MANIFEST = '.module-skills.json';
497
- function readModuleSkillsManifest(skillsDir) {
498
- const m = readJsonSafe(path.join(skillsDir, MODULE_SKILLS_MANIFEST));
562
+ // The INSTANCE-side provenance record, deliberately a DIFFERENT filename from the
563
+ // core's (task 1003632). They are different artifacts: MODULE_SKILLS_MANIFEST lists
564
+ // the untracked copies materializeModuleSkillsIntoCore made inside a core CHECKOUT,
565
+ // and is asserted never to travel to an instance; this one lists what materializeClaude
566
+ // landed INTO an instance, and is what step 1c withdraws against. Sharing one name made
567
+ // the instance's own record look like a core dotfile that had leaked across the boundary.
568
+ const INSTANCE_SKILLS_MANIFEST = '.instance-skills.json';
569
+ function readSkillsManifest(skillsDir, file) {
570
+ const m = readJsonSafe(path.join(skillsDir, file));
499
571
  return m && m.skills && typeof m.skills === 'object' ? m : { skills: {} };
500
572
  }
573
+ function readModuleSkillsManifest(skillsDir) {
574
+ return readSkillsManifest(skillsDir, MODULE_SKILLS_MANIFEST);
575
+ }
576
+ function readInstanceSkillsManifest(skillsDir) {
577
+ return readSkillsManifest(skillsDir, INSTANCE_SKILLS_MANIFEST);
578
+ }
501
579
  function materializeModuleSkillsIntoCore({ coreRoot, dryRun = false, clean = false, log = () => {} }) {
502
580
  const skillsDir = path.join(coreRoot, '.claude', 'skills');
503
581
  const summary = { materialized: [], removed: [], refused: [], excluded: [], keptCore: [], files: 0, notes: [] };
@@ -559,4 +637,4 @@ if (require.main === module) {
559
637
  process.exit(s.refused.length ? 1 : 0);
560
638
  }
561
639
 
562
- module.exports = { scriptToVerb, bongosify, transformSettings, copyTreeTransformed, materializeClaude, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
640
+ module.exports = { scriptToVerb, bongosify, transformSettings, copyTreeTransformed, materializeClaude, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, readInstanceSkillsManifest, INSTANCE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
package/src/bongos/db.js CHANGED
@@ -517,11 +517,12 @@ async function getLatestRankChangeForBuilder(builderId) {
517
517
  // Graceful builder exit (V3.R88 #307). Deactivating a builder is reversible
518
518
  // and lossless: drachmae (credits) + credit_log, shipped tasks, and historical
519
519
  // session_logs all stay. What changes: status → inactive (blocks new claims via
520
- // claimTask's guard), rank → 'xenos' (sandboxed tier), active claims released
521
- // cleanly with a final 'abandoned' session_log sealing each, and the tasks
522
- // returned to 'ready' for someone else. Reactivation flips status back; rank
523
- // is NOT auto-restored (an Archon re-promotes deliberately via PATCH
524
- // /builders/:id/rank). Memory archival is local-first and handled by the
520
+ // claimTask's guard), rank → 'xenos' (sandboxed tier) WITH its seeded rank-role
521
+ // rotated to match (task 1003193 the direct SQL used to skip that), active claims
522
+ // released cleanly with a final 'abandoned' session_log sealing each, and the tasks
523
+ // returned to 'ready' for someone else. Reactivation flips status back and re-syncs
524
+ // the rank-role; rank itself is NOT auto-restored (an Archon re-promotes deliberately
525
+ // via PATCH /builders/:id/rank). Memory archival is local-first and handled by the
525
526
  // /builder-exit skill — the server never holds another builder's local memory
526
527
  // (the server-side memory store is criterion #54's job, not this task's).
527
528
 
@@ -571,6 +572,18 @@ async function deactivateBuilder({ builderId, reason, actorBuilderId }) {
571
572
  deactivated_at, reactivated_at, deactivated_reason`,
572
573
  [builderId, reason ?? null]
573
574
  );
575
+ // BV1.R95b — rotate the seeded rank-role IN THIS TRANSACTION, exactly as
576
+ // _applyRankChangeTx does (task 1003193). The UPDATE above forces rank to
577
+ // 'xenos' by direct SQL rather than through setBuilderRank, so without this the
578
+ // builder's OLD seeded rank-role (archon, say) survives in
579
+ // government_builder_ranks — getRankKeysForBuilder has no builders.status filter.
580
+ // It is not a live grant while they are inactive (every session is revoked below
581
+ // and requireBuilder denies 'inactive' per request), but reactivateBuilder flips
582
+ // status back without touching rank, so the stale-high role would return live.
583
+ // The rank is read back from the row just written rather than re-spelled here.
584
+ // Deliberately uncaught, for the same reason as the twin: a committed
585
+ // deactivation whose rotation failed IS the privilege-retention bug.
586
+ await syncGovernanceRankRole(client, builderId, updated[0].rank);
574
587
  // Interim audit (→ audit_log when R36 lands). delta=0 = no-op for credits.
575
588
  await client.query(
576
589
  `INSERT INTO credit_log (builder_id, task_id, delta, reason)
@@ -629,6 +642,15 @@ async function reactivateBuilder({ builderId, actorBuilderId }) {
629
642
  );
630
643
  // Rank is intentionally left at whatever it is (xenos after a deactivation):
631
644
  // an Archon re-promotes deliberately via PATCH /builders/:id/rank.
645
+ //
646
+ // REACTIVATION IS THE EXPOSURE (task 1003193). Rank does not move here, but
647
+ // status flipping back to 'active' makes whatever seeded rank-role the builder
648
+ // still holds live again — getActiveBuildersWithRank, builderHoldsRankActive,
649
+ // the board roll and the R10 tally denominator all read it. So sync to the row's
650
+ // CURRENT rank: a stale-high role left by a deactivation predating this fix (or
651
+ // drifted in from anywhere else) is removed as PART of the reactivation rather
652
+ // than after it. Idempotent — a consistent builder costs two SELECTs, no write.
653
+ await syncGovernanceRankRole(client, builderId, updated[0].rank);
632
654
  await client.query(
633
655
  `INSERT INTO credit_log (builder_id, task_id, delta, reason)
634
656
  VALUES ($1, NULL, 0, $2)`,
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.641'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.643'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');