@bongos/core 1.19.482 → 1.19.484

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.
@@ -1401,5 +1401,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1401
1401
  landed since 1.19.480 with no explicit bump. run 33670051626. (task 1002620)
1402
1402
  1.19.482 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1403
1403
  landed since 1.19.481 with no explicit bump. run 33670786915. (task 1002620)
1404
+ 1.19.483 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1405
+ landed since 1.19.482 with no explicit bump. run 33674657478. (task 1002620)
1406
+ 1.19.484 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1407
+ landed since 1.19.483 with no explicit bump. run 33675143968. (task 1002620)
1404
1408
  ---------------------------------------------------------------------------
1405
1409
  ```
@@ -173,6 +173,11 @@
173
173
  // (same pattern as lastNeedsState). These are un-landed confirmed tasks that
174
174
  // GATE new claims and whose reward is pending until they land on ship.
175
175
  let lastRebaseState = null;
176
+ // task 1003464: the two halves of the greeting's status sentence — the
177
+ // public version progress and how many tasks the claimable feed returned.
178
+ // Null until each lands; the sentence says only what it knows.
179
+ let lastProgressState = null;
180
+ let lastClaimableCount = null;
176
181
 
177
182
  // Cached /me payload from the most recent loadAll() call. Used by the
178
183
  // welcome-chip reopen path (#408) so we can re-render the welcome panel
@@ -891,6 +896,10 @@
891
896
  if (!wrap || !list) return;
892
897
  try {
893
898
  const resp = await getJSON(`${API}/tasks/claimable`, { softAuth: true });
899
+ // task 1003464: the same feed answers the greeting's "N tasks are
900
+ // claimable right now" — rank-scoped, so it is THIS builder's number.
901
+ lastClaimableCount = resp && Array.isArray(resp.claimable) ? resp.claimable.length : null;
902
+ renderGreeting();
894
903
  const rows = (resp && Array.isArray(resp.claimable) ? resp.claimable : []).slice(0, 2);
895
904
  if (!rows.length) { wrap.hidden = true; if (subhead) subhead.hidden = true; return; }
896
905
  const cur = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits';
@@ -1039,6 +1048,7 @@
1039
1048
  window.__buildersSelfId = me.builder?.id ?? null;
1040
1049
  renderWelcome(me.builder, me.onboarding || null);
1041
1050
  renderProfile(me.builder);
1051
+ renderGreeting(); // task 1003464: "Good morning, <name>" — the h1 — as soon as /me answers
1042
1052
  // #731: the CLI-token re-issue card (V3.R91 #401) and the Discord connect/
1043
1053
  // link card (F7, ADR 0033) moved to /builders/settings — the hall's Standing
1044
1054
  // card is identity-only now.
@@ -1087,13 +1097,19 @@
1087
1097
  // down the page — log and show an inline placeholder.
1088
1098
  // #726: the per-skill model picker (Voices) moved to /builders/settings,
1089
1099
  // so the hall no longer fetches /me/skill-prefs here.
1090
- const [achAll, ships, versionsResp, lb] = await Promise.allSettled([
1100
+ const [achAll, ships, versionsResp, lb, progress] = await Promise.allSettled([
1091
1101
  getJSON(`${API}/achievements`),
1092
1102
  getJSON(`${API}/me/recent-ships?limit=all`),
1093
1103
  getJSON(`${API}/versions`),
1094
1104
  getJSON(`${API}/leaderboard`),
1105
+ getJSON(`${API}/public/progress`), // task 1003464: "<version> is N percent shipped"
1095
1106
  ]);
1096
1107
 
1108
+ if (progress.status === 'fulfilled') {
1109
+ lastProgressState = progress.value;
1110
+ renderGreeting();
1111
+ }
1112
+
1097
1113
  if (achAll.status === 'fulfilled') {
1098
1114
  // Show top 10 by sort_order — already ordered server-side. (Set is locked at 8
1099
1115
  // for V2, so the slice is defensive.) Unlocked + locked-with-progress come
@@ -1314,13 +1330,15 @@
1314
1330
  // reads what this page holds NOW rather than what it held at boot. Everything
1315
1331
  // else is stable for the life of the page. This call sits AFTER every
1316
1332
  // declaration it passes — moving it earlier is a temporal-dead-zone crash.
1317
- const { SETUP_CONFIG_KEY, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
1333
+ const { SETUP_CONFIG_KEY, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderGreeting, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
1318
1334
  API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd,
1319
1335
  get instanceRepo() { return instanceRepo; },
1336
+ get lastClaimableCount() { return lastClaimableCount; },
1320
1337
  get lastCostState() { return lastCostState; },
1321
1338
  get lastKarmaState() { return lastKarmaState; },
1322
1339
  get lastMeState() { return lastMeState; },
1323
1340
  get lastNeedsState() { return lastNeedsState; },
1341
+ get lastProgressState() { return lastProgressState; },
1324
1342
  get lastRebaseState() { return lastRebaseState; },
1325
1343
  get newcomerGated() { return newcomerGated; },
1326
1344
  });
@@ -272,6 +272,13 @@
272
272
  host.hidden = true; // stays hidden until a slice finds something actionable
273
273
  host.innerHTML =
274
274
  '<h2 id="attention-h" class="scroll__h">Needs your attention<span class="count-pill" id="attention-count" hidden></span></h2>' +
275
+ // task 1003464: the system's own notices — rebase owed, an input the
276
+ // system needs, the monthly budget — used to stack as banners INSIDE the
277
+ // Standing card. They are lines here now (rendered by hall-render.js
278
+ // renderSystemNotices from the /me state it already holds), first because
279
+ // a paused queue outranks an invitation. Rows carry .ginbox__item so
280
+ // refresh() counts them.
281
+ '<div id="attention-system"></div>' +
275
282
  '<div id="attention-goals"></div>' +
276
283
  // task 1002802 (P06): "Complete your profile" — a DISMISSIBLE nudge when
277
284
  // bio or preferred_disciplines are empty. Rendered by builders.js (which
@@ -508,7 +508,7 @@
508
508
  </div>
509
509
  <div class="profile__disciplines">
510
510
  <span class="profile__disc-label">Preferred work:</span>
511
- <span class="profile__disc-value">${prefs.length ? escapeHtml(prefs.join(', ')) : 'none chosen yet'}</span>
511
+ <span class="profile__disc-value">${prefs.length ? escapeHtml(prefs.join(', ')) : PREFS_EMPTY}</span>
512
512
  <a class="profile__disc-edit" href="/settings">Change in Settings →</a>
513
513
  </div>
514
514
  </div>
@@ -542,30 +542,121 @@
542
542
  const statsSection = document.getElementById('home-stats');
543
543
  if (statsSection && !ctx.newcomerGated) statsSection.hidden = false;
544
544
 
545
+ // task 1003464: the Home card as the mock draws it — an identity LINE and a
546
+ // three-row fact ledger. The mock repeats Credits and the place even though
547
+ // the stat band above carries both; the card is built as approved, all three
548
+ // rows, because that content is also what makes it end level with Goals.
549
+ // The notices that used to stack in here are inbox lines now.
545
550
  const homeStanding = document.getElementById('home-standing-body');
546
551
  if (homeStanding) {
552
+ const place = rollPlace ? `${ordinal(rollPlace.place)} of ${fmtNum(rollPlace.total)}` : '—';
547
553
  homeStanding.innerHTML = `
548
- ${rebaseBanner}
549
- ${needsBanner}
550
- ${budgetBanner}
551
- <div class="profile profile--compact">
552
- ${avatar}
553
- <div>
554
- <div class="profile__name-row">
555
- <p class="profile__name">${escapeHtml(builder.display_name || builder.github_login || 'Builder')}</p>
556
- ${rankBadge}
557
- </div>
558
- <p class="profile__handle">@${escapeHtml(builder.github_login || 'unknown')}</p>
559
- <div class="profile__stats">
560
- <span>Member since ${fmtDate(builder.created_at)}</span>
561
- <span>${prefs.length ? escapeHtml(prefs.join(', ')) : 'no preferred work yet'}</span>
562
- </div>
554
+ <div class="home-who">
555
+ ${builder.avatar_url
556
+ ? `<img class="home-who__avatar" src="${escapeHtml(builder.avatar_url)}" alt="">`
557
+ : '<span class="home-who__avatar" aria-hidden="true"></span>'}
558
+ <div class="home-who__meta">
559
+ <p class="home-who__name">${escapeHtml(builder.display_name || builder.github_login || 'Builder')}</p>
560
+ <p class="home-who__handle">@${escapeHtml(builder.github_login || 'unknown')}</p>
563
561
  </div>
562
+ <span class="home-who__rank">${builder.rank ? renderRankBadge(normalizeRank(builder.rank), { tip: false }) : ''}</span>
564
563
  </div>
564
+ <dl class="home-facts">
565
+ <div><dt>${escapeHtml(curLabel(true))}</dt><dd>${fmtNum(builder.total_credits)}</dd></div>
566
+ <div><dt>On the board</dt><dd>${place}</dd></div>
567
+ <div><dt>Preferred work</dt><dd>${prefs.length ? escapeHtml(prefs.join(', ')) : PREFS_EMPTY}</dd></div>
568
+ </dl>
565
569
  `;
566
570
  }
567
571
  const homeStandingSection = document.getElementById('home-standing');
568
572
  if (homeStandingSection && !ctx.newcomerGated) homeStandingSection.hidden = false;
573
+ renderSystemNotices({ rebaseTasks, need, cost });
574
+ }
575
+
576
+ // The ONE string for "no preferred work chosen" — the mock's copy — on both
577
+ // Standing surfaces (the Home card and the full view), so they cannot drift.
578
+ const PREFS_EMPTY = 'Not set yet';
579
+
580
+ const ordinal = (n) => {
581
+ const v = Number(n) || 0;
582
+ const s = (v % 100 >= 11 && v % 100 <= 13) ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[v % 10] || 'th');
583
+ return `${v}${s}`;
584
+ };
585
+
586
+ // task 1003464: the three system notices as inbox LINES on Home (the
587
+ // placement rule in modules/hall-ui/CLAUDE.md: an action awaiting the builder
588
+ // is a line item in Needs-attention, never a card). Only what awaits the
589
+ // builder lands here — a "we cover it" note is information, and stays on the
590
+ // full Standing view with the other two, where the banners still render.
591
+ function renderSystemNotices({ rebaseTasks, need, cost }) {
592
+ const box = document.getElementById('attention-system');
593
+ if (!box) return;
594
+ const rows = [];
595
+ if (rebaseTasks.length) {
596
+ const refs = rebaseTasks.map((t) => {
597
+ const id = String(t.id);
598
+ const href = window.OTBTaskDetail && typeof window.OTBTaskDetail.href === 'function' ? window.OTBTaskDetail.href(id) : `/task/${encodeURIComponent(id)}`;
599
+ return `<a href="${escapeHtml(href)}">task ${escapeHtml(id)}</a>`;
600
+ }).join(', ');
601
+ const one = rebaseTasks.length === 1;
602
+ rows.push(`<li class="ginbox__item ginbox__item--danger"><div class="ginbox__body">` +
603
+ `<span class="ginbox__kind">Rebase owed</span>` +
604
+ `<span class="ginbox__line">New claims are paused: ${rebaseTasks.length} confirmed ${one ? 'task' : 'tasks'} can't land yet, so ${one ? 'its' : 'their'} reward is pending. Rebase and re-ship, or release: ${refs}.</span>` +
605
+ `</div></li>`);
606
+ }
607
+ if (need && need.state === 'action_needed') {
608
+ const act = need.action && need.action.href
609
+ ? `<div class="ginbox__actions"><a class="btn-ghost" href="${escapeHtml(need.action.href)}">${escapeHtml(need.action.label || 'Set it up')}</a></div>`
610
+ : '';
611
+ rows.push(`<li class="ginbox__item ginbox__item--warn"><div class="ginbox__body">` +
612
+ `<span class="ginbox__kind">${escapeHtml(need.title)}</span>` +
613
+ `<span class="ginbox__line">${escapeHtml(need.message)}</span>` +
614
+ `</div>${act}</li>`);
615
+ }
616
+ if (cost && (cost.level === 'warn' || cost.level === 'over')) {
617
+ const over = cost.level === 'over';
618
+ rows.push(`<li class="ginbox__item ginbox__item--${over ? 'danger' : 'warn'}"><div class="ginbox__body">` +
619
+ `<span class="ginbox__kind">${over ? 'Monthly budget exceeded' : 'Approaching monthly budget'}</span>` +
620
+ `<span class="ginbox__line">$${escapeHtml(String(cost.spend_mtd_usd))} of $${escapeHtml(String(cost.budget_mtd_usd))} this month (${escapeHtml(String(cost.pct_used))}%). ${over ? 'Shipping and art generation are paused until an Archon raises your budget.' : 'At 100% these actions pause until an Archon raises your budget.'}</span>` +
621
+ `</div></li>`);
622
+ }
623
+ box.innerHTML = rows.length ? `<h3 class="ginbox__sec-h">From the system</h3><ul class="ginbox">${rows.join('')}</ul>` : '';
624
+ if (window.OTBAttention && typeof window.OTBAttention.refresh === 'function') window.OTBAttention.refresh();
625
+ }
626
+
627
+ // task 1003464: the greeting IS Home's one h1 (the mock opens with it), and
628
+ // the sub-line is the live status sentence — how far the version is and
629
+ // whether there is anything to do — the one line a returning builder needs.
630
+ // Each half renders as its data lands; the line stays hidden until one has.
631
+ const SMALL_NUMBERS = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'];
632
+ const greetingWord = (hour) => (hour < 12 ? 'Good morning' : (hour < 18 ? 'Good afternoon' : 'Good evening'));
633
+ // The version the sentence is about: the one being built with the most work in it.
634
+ function currentVersion(progress) {
635
+ const rows = progress && Array.isArray(progress.progress) ? progress.progress : [];
636
+ return rows.filter((r) => r && r.status === 'building')
637
+ .sort((a, b) => (Number(b.total_count) || 0) - (Number(a.total_count) || 0))[0] || null;
638
+ }
639
+ function renderGreeting() {
640
+ const h1 = document.getElementById('hall-h1');
641
+ const sub = document.getElementById('hall-sub');
642
+ const builder = ctx.lastMeState && ctx.lastMeState.builder;
643
+ if (h1 && builder) {
644
+ h1.textContent = `${greetingWord(new Date().getHours())}, ${builder.display_name || builder.github_login || 'builder'}`;
645
+ }
646
+ if (!sub) return;
647
+ const parts = [];
648
+ const v = currentVersion(ctx.lastProgressState);
649
+ if (v) parts.push(`${v.version_id} is ${Math.round(Number(v.percent_complete) || 0)} percent shipped.`);
650
+ const n = ctx.lastClaimableCount;
651
+ if (typeof n === 'number') {
652
+ if (n === 0) parts.push('Nothing is claimable right now.');
653
+ else {
654
+ const word = n < SMALL_NUMBERS.length ? SMALL_NUMBERS[n] : fmtNum(n);
655
+ parts.push(`${word.charAt(0).toUpperCase()}${word.slice(1)} ${n === 1 ? 'task is' : 'tasks are'} claimable right now.`);
656
+ }
657
+ }
658
+ sub.textContent = parts.join(' ');
659
+ sub.hidden = parts.length === 0;
569
660
  }
570
661
  const ONBOARDING_STAGE_ORDER = [
571
662
  'auth_complete',
@@ -1116,6 +1207,6 @@
1116
1207
  return idx === -1 ? null : { place: idx + 1, total: ranked.length };
1117
1208
  }
1118
1209
 
1119
- return { SETUP_CONFIG_KEY, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey };
1210
+ return { SETUP_CONFIG_KEY, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderGreeting, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey };
1120
1211
  };
1121
1212
  })();
@@ -34,7 +34,17 @@
34
34
  it sets document.title + this element's text from the branding pack.
35
35
  The visible brand mark lives in the sidebar (shell.js reads the pack
36
36
  directly); this stays as the invisible chrome hook + a11y page title. -->
37
- <h1 id="hall-h1" class="sr-only">Builders' Hall</h1>
37
+ <!-- task 1003464: the mock opens with a greeting, and it IS the page's one h1
38
+ (one-header-main-h1 — never a second one). The static text is the no-JS /
39
+ signed-out fallback and what brand-apply.js writes the hall's name into;
40
+ hall-render.js renderGreeting turns it into "Good morning, <name>" once
41
+ /me answers, and fills the sub-line — the live status sentence (how far
42
+ the version is, whether anything is claimable) — hidden until it has
43
+ something to say. The label voice is the board's own (.page-head--board). -->
44
+ <div class="page-head page-head--board page-head--hall">
45
+ <h1 id="hall-h1" class="page-head__h">Builders' Hall</h1>
46
+ <p id="hall-sub" class="page-head__sub" hidden></p>
47
+ </div>
38
48
 
39
49
  <!-- BV1.R75 (ADR 0062 §4): hall widgets are NOT hardcoded here. Every widget
40
50
  — core (hall-widgets.js) and module-contributed (a module's own client
@@ -926,11 +926,13 @@ main:focus { outline: none; }
926
926
  }
927
927
 
928
928
  .app-views .view > .view-zone + .view-zone { margin-top: var(--sp-md); }
929
+ /* No align-items (task 1003464): the mock's .band declares none, so the grid
930
+ default — stretch — makes both cards fill the row and end level. `start` let
931
+ each card shrink to its content, and Standing stopped short under Goals. */
929
932
  .view-zone--band {
930
933
  display: grid;
931
934
  grid-template-columns: 1fr 1fr;
932
935
  gap: var(--sp-md);
933
- align-items: start;
934
936
  }
935
937
  .view-zone--band > .scroll { margin: 0; }
936
938
  .view-zone:empty { display: none; }
@@ -2782,6 +2784,73 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
2782
2784
  .ginbox__note { margin: 3px 0 0; font-size: 12.5px; color: var(--ink-soft); font-style: italic; }
2783
2785
  .ginbox__actions { flex: 0 0 auto; display: flex; gap: 6px; }
2784
2786
  .ginbox__pending { font-size: 12.5px; margin: 10px 0 0; color: var(--ink-soft); }
2787
+ /* task 1003464: the system's notices are inbox LINES on Home (rebase owed, an
2788
+ input the system needs, the monthly budget) — the placement rule, not a
2789
+ banner inside the Standing card. A row is a kind in the label voice over one
2790
+ line of ink; tone marks the exception, on the kind only. */
2791
+ .ginbox__kind {
2792
+ font-family: var(--label-face);
2793
+ font-size: var(--label-size);
2794
+ letter-spacing: var(--label-space);
2795
+ text-transform: var(--label-case);
2796
+ color: var(--ink-faint);
2797
+ }
2798
+ .ginbox__line { font-size: 13.5px; color: var(--ink); }
2799
+ /* a task ref inside a line (rebase owed) is a link in prose: the same 24px
2800
+ row recipe as the row links below, or it sits under the floor at 13.5px */
2801
+ .ginbox__line a {
2802
+ display: inline-block;
2803
+ min-height: 24px;
2804
+ padding: 3px 0;
2805
+ line-height: 18px;
2806
+ box-sizing: border-box;
2807
+ color: var(--ink);
2808
+ text-decoration: underline;
2809
+ text-underline-offset: 3px;
2810
+ }
2811
+ .ginbox__item--warn .ginbox__kind { color: var(--warn); }
2812
+ .ginbox__item--danger .ginbox__kind { color: var(--danger); }
2813
+ /* The link in a Home row takes the mock's row recipe (.att__line a, .g__t): 3px
2814
+ of vertical padding lifts a 13–14px line to the 24px floor without re-spacing
2815
+ the row. The populated harness measured these at 21–23px (task 1003464); the
2816
+ kit's own stub never showed it, because it serves no rows. */
2817
+ .ginbox__goal, .triage-inbox__link, .next-up__title {
2818
+ display: inline-block;
2819
+ min-height: 24px;
2820
+ padding: 3px 0;
2821
+ line-height: 18px;
2822
+ box-sizing: border-box;
2823
+ }
2824
+
2825
+ /* ---------- Home: the Standing card as the mock draws it (task 1003464) ----------
2826
+ docs/design/mocks/hall/board/v3.html: an identity LINE (a 36px avatar, the
2827
+ name over the handle, the rank on the right) and a three-row fact ledger —
2828
+ Credits, On the board, Preferred work. With .view-zone--band stretching its
2829
+ two cards, this content is what makes Standing end level with Goals. */
2830
+ .home-who { display: flex; align-items: center; gap: 12px; padding-bottom: 14px; }
2831
+ .home-who__avatar {
2832
+ width: 36px; height: 36px; flex: none;
2833
+ border-radius: 50%;
2834
+ background: var(--skeleton);
2835
+ border: 1px solid var(--rule);
2836
+ object-fit: cover;
2837
+ }
2838
+ .home-who__meta { display: flex; flex-direction: column; min-width: 0; }
2839
+ .home-who__name { margin: 0; font-size: 15px; font-weight: 500; color: var(--ink); }
2840
+ .home-who__handle { margin: 0; font-family: var(--font-mono); font-size: 12px; color: var(--ink-faint); }
2841
+ .home-who__rank { margin-left: auto; }
2842
+ .home-facts { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 0; }
2843
+ .home-facts > div { display: contents; }
2844
+ .home-facts dt, .home-facts dd { margin: 0; padding: 9px 0; border-top: 1px solid var(--rule-soft); }
2845
+ .home-facts dt {
2846
+ font-family: var(--label-face);
2847
+ font-size: var(--label-size);
2848
+ letter-spacing: var(--label-space);
2849
+ text-transform: var(--label-case);
2850
+ color: var(--ink-faint);
2851
+ }
2852
+ .home-facts dd { text-align: right; font-size: 14px; color: var(--ink); font-variant-numeric: tabular-nums; }
2853
+ .home-facts > div:first-child dt, .home-facts > div:first-child dd { border-top: 0; padding-top: 0; }
2785
2854
 
2786
2855
  /* ---------- hall-kit components (task 2166) ----------
2787
2856
  Styles for the shared kit (hall-kit.js / window.OTBKit): pager bar,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.482",
3
+ "version": "1.19.484",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.482",
9
+ "version": "1.19.484",
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.482",
3
+ "version": "1.19.484",
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",
@@ -67,18 +67,49 @@ async function printRelevantLearnings(task) {
67
67
  // invalid map, or a discipline with no entry (engineer/artist today), prints
68
68
  // nothing and never breaks a successful claim.
69
69
  function printDisciplineMode(task) {
70
+ let mode = null;
70
71
  try {
71
72
  const discipline = task && task.discipline;
72
73
  if (!discipline) return;
73
74
  const raw = fs.readFileSync(path.join(__dirname, 'discipline-modes.json'), 'utf8');
74
75
  const modes = (JSON.parse(raw) || {}).modes || {};
75
- const mode = modes[discipline];
76
+ mode = modes[discipline];
76
77
  if (!mode || !mode.directive) return;
77
78
  console.log('');
78
79
  console.log(`▶ ${mode.label || 'Working mode'} — /${mode.skill}`);
79
80
  console.log(` ${mode.directive}`);
80
81
  } catch (_) {
81
82
  // never let the mode hint break a successful claim
83
+ return;
84
+ }
85
+ materializeModeSkills(mode);
86
+ }
87
+
88
+ // A task brings its own tools (task 1003486). A mode whose entry opts in with
89
+ // `materializeModuleSkills` lands the module-owned skills its playbook names — for
90
+ // `ui`, the ui-design taste kit /design sends the session to use — into THIS
91
+ // checkout's .claude/skills/, as the untracked, self-ignored copies ADR 0224 defined.
92
+ // Without it a builder is routed to /design, reads that impeccable and minimalist-ui
93
+ // are the tools, and finds none of them on the machine with no error saying why.
94
+ // Carried by the claim rather than a hook or a session start because a workbench is
95
+ // not re-provisioned every session (owner decision 2026-09-02): it runs only when
96
+ // someone actually claims work of that discipline.
97
+ //
98
+ // Fail-open in every branch, like the hint above it: the server already granted the
99
+ // claim, so nothing here may throw past this point — a failure prints the one command
100
+ // that does it by hand. The require is lazy so a claim of any other discipline never
101
+ // pays for loading the materialiser (and a broken materialiser cannot stop a claim).
102
+ function materializeModeSkills(mode) {
103
+ if (!mode || mode.materializeModuleSkills !== true) return;
104
+ try {
105
+ const { materializeModuleSkillsIntoCore, isCoreCheckoutRoot } = require('./claude-materialize');
106
+ const coreRoot = path.resolve(__dirname, '..', '..');
107
+ if (!isCoreCheckoutRoot(coreRoot)) return; // a scaffolded instance already has them from `bongos init`
108
+ const s = materializeModuleSkillsIntoCore({ coreRoot });
109
+ const n = s.materialized.length;
110
+ if (n) console.log(` ✓ ${n} module-owned skill(s) for this mode are in .claude/skills/ (untracked copies — ADR 0224).`);
111
+ } catch (e) {
112
+ console.log(` ⚠ could not land this mode's module-owned skills — run \`node scripts/gds/claude-materialize.js --module-skills-only\` (${e && e.message}).`);
82
113
  }
83
114
  }
84
115
 
@@ -638,4 +669,4 @@ if (require.main === module) {
638
669
  });
639
670
  }
640
671
 
641
- module.exports = { formatClaimFailure, claimErrorInfo, goalMembershipOfferPlan };
672
+ module.exports = { formatClaimFailure, claimErrorInfo, goalMembershipOfferPlan, materializeModeSkills };
@@ -28,6 +28,7 @@ const path = require('node:path');
28
28
  const { spawnSync } = require('node:child_process');
29
29
  const { VERBS } = require('../../bin/bongos.js'); // side-effect-free require (guarded main)
30
30
  const ic = require('../../src/instance-config'); // neutral configDir/envPrefix fallbacks (never a founder literal)
31
+ const { isCorePackageName } = require('../../src/module-loader/catalog'); // one definition of "this checkout IS the core"
31
32
  const { renderInstanceClaudeMd } = require('./claude-md-template'); // task 2104 — the starter CLAUDE.md
32
33
 
33
34
  // script basename (e.g. "ship.js") → dedicated bongos verb, for verbs that map to a bare script with
@@ -469,6 +470,24 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
469
470
  // core's own tracked .claude/skills/ carries is left to the core. Idempotent: every run first
470
471
  // removes what the last manifest named (so a skill a module stopped declaring does not
471
472
  // linger), then lands the current set; `clean` stops after the removal.
473
+ // Is `root` the core CHECKOUT — a workbench a builder edits — rather than the core
474
+ // package installed under a scaffolded instance's node_modules? Only a checkout's own
475
+ // .claude/skills/ is the directory Claude Code reads for that project, so only there
476
+ // does materialising the module-owned skills put them in front of anyone; an instance
477
+ // already received them at ITS root from `bongos init`, and a second copy under
478
+ // node_modules/@bongos/core would be invisible (task 1003486). Fail-soft by
479
+ // construction: an unreadable package.json answers "not the core checkout", which is
480
+ // the conservative no-op rather than a copy into a tree we could not identify.
481
+ function isCoreCheckoutRoot(root) {
482
+ try {
483
+ const abs = path.resolve(root);
484
+ if (abs.split(/[\\/]+/).includes('node_modules')) return false;
485
+ return isCorePackageName(JSON.parse(fs.readFileSync(path.join(abs, 'package.json'), 'utf8')).name);
486
+ } catch (_) {
487
+ return false;
488
+ }
489
+ }
490
+
472
491
  const MODULE_SKILLS_MANIFEST = '.module-skills.json';
473
492
  function readModuleSkillsManifest(skillsDir) {
474
493
  const m = readJsonSafe(path.join(skillsDir, MODULE_SKILLS_MANIFEST));
@@ -535,4 +554,4 @@ if (require.main === module) {
535
554
  process.exit(s.refused.length ? 1 : 0);
536
555
  }
537
556
 
538
- module.exports = { scriptToVerb, bongosify, transformSettings, copyTreeTransformed, materializeClaude, materializeModuleSkillsIntoCore, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
557
+ 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 };
@@ -1,5 +1,5 @@
1
1
  {
2
- "_doc": "Discipline -> operating-playbook map (task 1267). A discipline whose work is best done as a *mode of working* rather than the default claim->build->ship loop names the skill that IS its playbook here. claim.js reads this after a successful claim: if the claimed task's `discipline` has an entry, it prints the entry's `directive` so the session opens the right playbook. There is NO classification step at claim time — `discipline` is already a column on the task. This is the whole discipline-fork mechanism: one carrier (the claim the builder already runs), one data lookup, no hooks, the Conductor untouched. Absence of an entry is the default: engineer and artist are intentionally NOT listed, so their claim output is unchanged and they stay in the standard build-and-ship loop. To give a discipline its own experience later, add an entry here and write the skill — no code change in claim.js. A missing or invalid version of this file must never break a claim (claim.js fails open).",
2
+ "_doc": "Discipline -> operating-playbook map (task 1267). A discipline whose work is best done as a *mode of working* rather than the default claim->build->ship loop names the skill that IS its playbook here. claim.js reads this after a successful claim: if the claimed task's `discipline` has an entry, it prints the entry's `directive` so the session opens the right playbook. There is NO classification step at claim time — `discipline` is already a column on the task. This is the whole discipline-fork mechanism: one carrier (the claim the builder already runs), one data lookup, no hooks, the Conductor untouched. Absence of an entry is the default: engineer and artist are intentionally NOT listed, so their claim output is unchanged and they stay in the standard build-and-ship loop. To give a discipline its own experience later, add an entry here and write the skill — no code change in claim.js. A missing or invalid version of this file must never break a claim (claim.js fails open). A mode entry may also set `\"materializeModuleSkills\": true` to have the claim land that mode's module-owned skills into this checkout's .claude/skills/ (task 1003486) — opt-in per discipline, core-checkout only, and fail-open like the hint itself, so a discipline that omits it claims exactly as before.",
3
3
  "modes": {
4
4
  "engineer": {
5
5
  "skill": "dev",
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "ui": {
20
20
  "skill": "design",
21
+ "materializeModuleSkills": true,
21
22
  "label": "UI design session",
22
23
  "directive": "This is a UI task — interface DESIGN work (layout, wireframes, design systems, Figma / Claude-Design), split out of the pixel-art `artist` lane (ADR 0079). To work it, invoke the /design skill — the operating playbook for UI-design sessions, the only place the experience is defined. Like /paint it is show-first: communicate in mock-ups and rendered screens, not paragraphs about layout decisions — stage the change on your sandbox preview (/builder-stage) and look at the real interface before shipping. Keep the locked branding (config/branding.json) and the existing component conventions of the surface you touch (modules/status-ui, modules/hall-ui, public/game/ui). If no human is present (autonomous / bypass-permissions), produce the design, stage it for review, and park the screens rather than shipping interface changes blind. Open /design now rather than working this task the default way."
23
24
  }
package/src/module-api.js CHANGED
@@ -34,7 +34,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
34
34
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
35
35
  // the entry to that file. Look for a version's history there, not here.
36
36
  // ---------------------------------------------------------------------------
37
- const CORE_VERSION = '1.19.482'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
+ const CORE_VERSION = '1.19.484'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
38
38
 
39
39
  // A namespaced logger so a module's log lines are attributable + consistent.
40
40
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -9,6 +9,9 @@
9
9
  // 3. transformSettings — rewrites only the scripts/gds hook commands, keeps .claude/hooks/ ones.
10
10
  // 4. materializeClaude — copies + transforms skills into a target dir (no leftover node scripts/gds),
11
11
  // writes settings.json, copies hooks; and NO-OPS when the target IS the core root (self-copy guard).
12
+ // 5. task 1003486 — a UI task brings its own tools: the `materializeModuleSkills` opt-in on a
13
+ // discipline-modes entry, the core-checkout probe that keeps it out of a scaffolded instance,
14
+ // and the fail-open contract (a materialise error never breaks a claim the server already granted).
12
15
  //
13
16
  // Run: node tests/claude_materialize.mjs
14
17
 
@@ -22,6 +25,7 @@ import { fileURLToPath } from 'node:url';
22
25
  const require = createRequire(import.meta.url);
23
26
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
24
27
  const m = require('../scripts/gds/claude-materialize.js');
28
+ const claim = require('../scripts/gds/claim.js');
25
29
 
26
30
  let passed = 0;
27
31
  let failed = 0;
@@ -484,5 +488,77 @@ t('--module-skills-only: the CLI entry refuses without the flag (usage, exit 2)
484
488
  rmSync(dir, { recursive: true, force: true });
485
489
  });
486
490
 
491
+ // ---- task 1003486: claiming a ui task lands /design's tools in the checkout ----
492
+
493
+ t('the materialise-at-claim trigger is DATA: the ui mode opts in, every other discipline claims exactly as before', () => {
494
+ const modes = JSON.parse(readFileSync(join(ROOT, 'scripts', 'gds', 'discipline-modes.json'), 'utf8')).modes;
495
+ assert.equal(modes.ui.materializeModuleSkills, true, 'a ui claim brings the skills /design names');
496
+ assert.equal(modes.ui.skill, 'design', 'and still routes to /design');
497
+ for (const d of ['engineer', 'ideator', 'artist']) {
498
+ assert.ok(!modes[d].materializeModuleSkills, `${d} did not opt in, so its claim output is unchanged`);
499
+ }
500
+ });
501
+
502
+ t('isCoreCheckoutRoot: true for a real core checkout, false for the core installed into an instance, false for anything unreadable', () => {
503
+ assert.equal(m.isCoreCheckoutRoot(ROOT), true, 'this checkout names itself the core and is not under node_modules');
504
+ assert.equal(m.isCoreCheckoutRoot(join(ROOT, 'node_modules', '@bongos', 'core')), false,
505
+ 'a scaffolded instance runs claim.js from node_modules — materialising there would be invisible');
506
+ const other = mkdtempSync(join(tmpdir(), 'cm-not-core-'));
507
+ writeFileSync(join(other, 'package.json'), JSON.stringify({ name: 'some-instance' }));
508
+ assert.equal(m.isCoreCheckoutRoot(other), false, 'another checkout is that instance, not the core');
509
+ assert.equal(m.isCoreCheckoutRoot(join(other, 'nope')), false, 'an unreadable tree answers no — the conservative no-op');
510
+ rmSync(other, { recursive: true, force: true });
511
+ });
512
+
513
+ t('materializeModeSkills: a discipline that did not opt in does nothing, and a materialise failure prints one hint instead of breaking the claim', () => {
514
+ const lines = [];
515
+ const realLog = console.log;
516
+ console.log = (...a) => lines.push(a.join(' '));
517
+ const mPath = require.resolve('../scripts/gds/claude-materialize.js');
518
+ const realExports = require.cache[mPath].exports;
519
+ try {
520
+ for (const mode of [null, undefined, {}, { skill: 'dev' }, { materializeModuleSkills: false }, { materializeModuleSkills: 'yes' }]) {
521
+ claim.materializeModeSkills(mode);
522
+ }
523
+ assert.deepEqual(lines, [], 'no opt-in => nothing printed and nothing copied');
524
+
525
+ // forced failure in the materialise step: swallowed, one line, the manual command named
526
+ require.cache[mPath].exports = {
527
+ isCoreCheckoutRoot: () => true,
528
+ materializeModuleSkillsIntoCore: () => { throw new Error('disk on fire'); },
529
+ };
530
+ claim.materializeModeSkills({ materializeModuleSkills: true });
531
+ assert.equal(lines.length, 1, 'exactly one line, not a wall');
532
+ assert.match(lines[0], /claude-materialize\.js --module-skills-only/, 'the hint names the command that does it by hand');
533
+ assert.match(lines[0], /disk on fire/, 'and why it could not');
534
+
535
+ // a tree that is not the core checkout is a no-op even with the flag on
536
+ lines.length = 0;
537
+ require.cache[mPath].exports = {
538
+ isCoreCheckoutRoot: () => false,
539
+ materializeModuleSkillsIntoCore: () => { throw new Error('must never be called in an instance'); },
540
+ };
541
+ claim.materializeModeSkills({ materializeModuleSkills: true });
542
+ assert.deepEqual(lines, [], 'in a scaffolded instance the claim is unchanged — init already landed them');
543
+ } finally {
544
+ require.cache[mPath].exports = realExports;
545
+ console.log = realLog;
546
+ }
547
+ });
548
+
549
+ t('materializeModeSkills lands the real module-owned skills, self-ignored, and is byte-identical on a re-claim', () => {
550
+ const core = fakeCore();
551
+ // the real function resolves its own coreRoot, so drive the library the way it does
552
+ const first = m.materializeModuleSkillsIntoCore({ coreRoot: core });
553
+ assert.deepEqual(first.materialized, ['alpha']);
554
+ const manifest1 = readFileSync(join(core, '.claude', 'skills', m.MODULE_SKILLS_MANIFEST), 'utf8');
555
+ const ignore = readFileSync(join(core, '.claude', 'skills', '.gitignore'), 'utf8');
556
+ assert.ok(ignore.split('\n').includes('/alpha/'), 'a re-claim leaves git status clean: the copy ignores itself');
557
+ const second = m.materializeModuleSkillsIntoCore({ coreRoot: core });
558
+ assert.deepEqual(second.materialized, ['alpha'], 'idempotent: the re-claim lands the same set');
559
+ assert.equal(readFileSync(join(core, '.claude', 'skills', m.MODULE_SKILLS_MANIFEST), 'utf8'), manifest1, 'byte-identical manifest');
560
+ rmSync(core, { recursive: true, force: true });
561
+ });
562
+
487
563
  console.log(`\nclaude_materialize: ${passed} passed, ${failed} failed`);
488
564
  process.exit(failed ? 1 : 0);