@bongos/core 1.19.372 → 1.19.374

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.
Files changed (32) hide show
  1. package/.bongos-core.json +54 -34
  2. package/.claude/skills/goal-create/SKILL.md +7 -4
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +5 -1
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0161-publish-on-merge.md +1 -1
  9. package/docs/adr/0180-dependency-intake-and-drift-policy.md +6 -0
  10. package/docs/api/openapi.json +79 -4
  11. package/docs/api-reference.md +5 -3
  12. package/docs/copy-inventory.md +6 -3
  13. package/docs/copy-registry.json +32 -5
  14. package/modules/builder-settings/profile-nudge-prefs.js +125 -0
  15. package/modules/builder-settings/routes/settings.js +48 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  17. package/modules/hall-ui/public/goal-inbox.js +10 -0
  18. package/modules/hall-ui/public/index.html +4 -0
  19. package/modules/hall-ui/public/profile-nudge.js +122 -0
  20. package/modules/lifecycle/goal-category.js +28 -0
  21. package/modules/lifecycle/routes/goals.js +10 -1
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/scripts/gds/bump-version.js +41 -1
  25. package/scripts/gds/fitness-checks-write-validation.js +1 -0
  26. package/scripts/gds/ship-land.js +94 -3
  27. package/src/module-api.js +5 -1
  28. package/tests/bump_version.mjs +77 -1
  29. package/tests/goal_category.mjs +28 -0
  30. package/tests/profile_nudge_prefs.mjs +97 -0
  31. package/tests/ship_push_diagnosis.mjs +78 -0
  32. package/tests/sound_player.mjs +38 -0
@@ -3,17 +3,17 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 1400,
7
- "unique_ids": 1241,
8
- "certain": 1400,
6
+ "strings": 1403,
7
+ "unique_ids": 1244,
8
+ "certain": 1403,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 129
11
11
  },
12
12
  "surfaces": {
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
- "files": 70,
16
- "strings": 1110
15
+ "files": 71,
16
+ "strings": 1113
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -1846,6 +1846,15 @@
1846
1846
  "origin": "html-text",
1847
1847
  "confidence": "certain"
1848
1848
  },
1849
+ {
1850
+ "id": "62eded408097",
1851
+ "surface": "builders-hall",
1852
+ "text": "Add {…}",
1853
+ "file": "modules/hall-ui/public/profile-nudge.js",
1854
+ "line": 68,
1855
+ "origin": "js-markup",
1856
+ "confidence": "certain"
1857
+ },
1849
1858
  {
1850
1859
  "id": "7cf0163d4cde",
1851
1860
  "surface": "builders-hall",
@@ -2764,6 +2773,15 @@
2764
2773
  "origin": "html-title",
2765
2774
  "confidence": "certain"
2766
2775
  },
2776
+ {
2777
+ "id": "cfc287c42263",
2778
+ "surface": "builders-hall",
2779
+ "text": "Complete your profile",
2780
+ "file": "modules/hall-ui/public/profile-nudge.js",
2781
+ "line": 62,
2782
+ "origin": "js-markup",
2783
+ "confidence": "certain"
2784
+ },
2767
2785
  {
2768
2786
  "id": "2867c85d196d",
2769
2787
  "surface": "builders-hall",
@@ -7552,6 +7570,15 @@
7552
7570
  "origin": "js-markup",
7553
7571
  "confidence": "certain"
7554
7572
  },
7573
+ {
7574
+ "id": "838b17fa8d93",
7575
+ "surface": "builders-hall",
7576
+ "text": "Shown on your public profile — it helps others know what you work on.",
7577
+ "file": "modules/hall-ui/public/profile-nudge.js",
7578
+ "line": 69,
7579
+ "origin": "js-markup",
7580
+ "confidence": "certain"
7581
+ },
7555
7582
  {
7556
7583
  "id": "2f12d46f96ca",
7557
7584
  "surface": "builders-hall",
@@ -0,0 +1,125 @@
1
+ // modules/builder-settings/profile-nudge-prefs.js — the "Complete your profile"
2
+ // nudge dismissal (task 1002802 / P06, goal 1000045).
3
+ //
4
+ // WHY A KNOB AND NOT A WIZARD. Decision 2 of the builder-profile spec
5
+ // (docs/specs/bongos-v1-builder-profile-page.md) rejected a profile-setup
6
+ // wizard: profiles are auto-created at first sign-in and enriched
7
+ // progressively, and an interstitial between sign-in and the hall adds friction
8
+ // at exactly the drop-off point. The replacement is a DISMISSIBLE line item in
9
+ // the needs-attention inbox — the hall's placement rule (modules/hall-ui/
10
+ // CLAUDE.md → Placement: an action awaiting the builder is an inbox line, never
11
+ // a new Home card).
12
+ //
13
+ // Dismissible has to mean dismissed-for-good, across devices: a builder who has
14
+ // decided not to write a bio should not be asked again on their laptop. So the
15
+ // flag is SERVER-side, not localStorage.
16
+ //
17
+ // Storage: one reserved key ('profile_nudge_dismissed') inside the
18
+ // `behavior_prefs` jsonb on `builders` (migration 119 — the same blob the
19
+ // wandering/render/interaction knobs use), so this adds NO migration. Value is
20
+ // a scalar boolean; a missing key means "not dismissed", which keeps the column
21
+ // sparse. applyPatch leaves sibling keys alone.
22
+ //
23
+ // This module is the single source of truth: GET/PATCH /api/gds/me/profile-nudge
24
+ // read and write it, and the hall's attention-card slice reads the resolved
25
+ // boolean to decide whether to render the line at all.
26
+
27
+ const RESERVED_KEY = 'profile_nudge_dismissed';
28
+ const DEFAULT_DISMISSED = false; // never dismissed until the builder says so
29
+
30
+ // ---- pure helpers ----
31
+
32
+ // The raw override a builder has stored, or null when unset/invalid. Kept
33
+ // separate from the effective value so a caller can tell "chose to keep it" from
34
+ // "never answered" (mirrors render-prefs' overrideEnabled).
35
+ function overrideDismissed(behaviorPrefs) {
36
+ const raw = behaviorPrefs && behaviorPrefs[RESERVED_KEY];
37
+ return typeof raw === 'boolean' ? raw : null;
38
+ }
39
+
40
+ // The resolved boolean — the value the hall acts on.
41
+ function nudgeDismissed(behaviorPrefs) {
42
+ const o = overrideDismissed(behaviorPrefs);
43
+ return o === null ? DEFAULT_DISMISSED : o;
44
+ }
45
+
46
+ // Which profile fields are still empty. PURE over the two inputs the nudge
47
+ // cares about, so the decision is testable without a DB or an HTTP round-trip.
48
+ // `bio` lives on the platform-identity account (hub-side), `preferred_disciplines`
49
+ // on `builders` — the caller passes both; this file never reaches for either.
50
+ // Whitespace-only bio counts as empty: a space is not a bio.
51
+ function missingProfileFields({ bio, preferredDisciplines } = {}) {
52
+ const missing = [];
53
+ if (typeof bio !== 'string' || bio.trim() === '') missing.push('bio');
54
+ if (!Array.isArray(preferredDisciplines) || preferredDisciplines.length === 0) missing.push('disciplines');
55
+ return missing;
56
+ }
57
+
58
+ // Should the nudge render? Only when something is actually missing AND the
59
+ // builder has not dismissed it. The two halves are deliberately separate above
60
+ // so a caller can say WHAT is missing even when the nudge is dismissed.
61
+ function shouldNudge({ behaviorPrefs, bio, preferredDisciplines } = {}) {
62
+ if (nudgeDismissed(behaviorPrefs)) return false;
63
+ return missingProfileFields({ bio, preferredDisciplines }).length > 0;
64
+ }
65
+
66
+ // Everything the API + the hall slice need in one object.
67
+ function describeForApi(behaviorPrefs, { bio, preferredDisciplines } = {}) {
68
+ const missing = missingProfileFields({ bio, preferredDisciplines });
69
+ return {
70
+ dismissed: nudgeDismissed(behaviorPrefs),
71
+ default_dismissed: DEFAULT_DISMISSED,
72
+ override: overrideDismissed(behaviorPrefs),
73
+ missing,
74
+ show: !nudgeDismissed(behaviorPrefs) && missing.length > 0,
75
+ };
76
+ }
77
+
78
+ // Validate a PATCH body: { dismissed: <bool> | null }. `null` clears the
79
+ // override (back to not-dismissed, so the nudge can return — the "undismiss"
80
+ // path a Settings toggle would use). Unknown keys / non-booleans → 422. An empty
81
+ // body is a valid no-op.
82
+ function validatePatch(body) {
83
+ if (body == null || typeof body !== 'object' || Array.isArray(body)) {
84
+ return { ok: false, errors: [{ error: 'body_must_be_object' }] };
85
+ }
86
+ const errors = [];
87
+ const patch = {};
88
+ for (const [key, value] of Object.entries(body)) {
89
+ if (key !== 'dismissed') {
90
+ errors.push({ key, error: 'unknown_key', allowed: ['dismissed'] });
91
+ continue;
92
+ }
93
+ if (value === null || typeof value === 'boolean') {
94
+ patch[RESERVED_KEY] = value; // null = clear → default (not dismissed)
95
+ continue;
96
+ }
97
+ errors.push({ key, error: 'invalid_value', value, allowed: 'boolean or null' });
98
+ }
99
+ if (errors.length > 0) return { ok: false, errors };
100
+ return { ok: true, patch };
101
+ }
102
+
103
+ // Apply a validated patch to a builder's existing behavior_prefs. Pure. null
104
+ // deletes the key; a boolean overwrites. Sibling keys (wandering, widgets,
105
+ // interaction) are left untouched.
106
+ function applyPatch(existing, patch) {
107
+ const next = { ...(existing || {}) };
108
+ for (const [key, value] of Object.entries(patch)) {
109
+ if (value === null) delete next[key];
110
+ else next[key] = value;
111
+ }
112
+ return next;
113
+ }
114
+
115
+ module.exports = {
116
+ RESERVED_KEY,
117
+ DEFAULT_DISMISSED,
118
+ overrideDismissed,
119
+ nudgeDismissed,
120
+ missingProfileFields,
121
+ shouldNudge,
122
+ describeForApi,
123
+ validatePatch,
124
+ applyPatch,
125
+ };
@@ -28,6 +28,7 @@ const wanderingPrefs = require('../wandering-prefs');
28
28
  const interactionPrefs = require('../interaction-prefs');
29
29
  const modelAllocPrefs = require('../model-alloc-prefs');
30
30
  const renderPrefs = require('../render-prefs');
31
+ const profileNudgePrefs = require('../profile-nudge-prefs');
31
32
  const displayName = require('../display-name');
32
33
  // task 1003208: structured logging (pino via the doorway) — was console.*.
33
34
  const log = api.logger('builder-settings');
@@ -378,6 +379,53 @@ module.exports = function buildBuilderSettingsRouter() {
378
379
  }
379
380
  });
380
381
 
382
+ // The "Complete your profile" nudge dismissal (task 1002802 / P06). The nudge
383
+ // itself is a DISMISSIBLE line item in the hall's needs-attention inbox — the
384
+ // replacement for the profile-setup wizard Decision 2 rejected. These two
385
+ // routes own only the dismissal FLAG; whether anything is actually missing is
386
+ // composed by the caller, which already holds both inputs (preferred_disciplines
387
+ // from GET /me, bio from GET /me/cross-project — bio lives on the platform
388
+ // identity account, and this module must never reach across to that one).
389
+ //
390
+ // rank: any-builder — own profile config; same posture as /me/render.
391
+ router.get('/me/profile-nudge', api.requireBuilder, async (req, res) => {
392
+ try {
393
+ // preferred_disciplines is NOT on req.builder (requireBuilder attaches an
394
+ // identity+rank subset), so read the row through the doorway — the same
395
+ // getBuilderById the wandering/interaction routes above use.
396
+ const [stored, builder] = await Promise.all([
397
+ db.getBuilderBehaviorPrefs(req.builder.id),
398
+ api.getBuilderById(req.builder.id),
399
+ ]);
400
+ res.json(profileNudgePrefs.describeForApi(stored, {
401
+ preferredDisciplines: builder && builder.preferred_disciplines,
402
+ }));
403
+ } catch (err) {
404
+ log.error('GET /me/profile-nudge', err);
405
+ res.fail('read_failed', { status: 500, message: 'internal error' });
406
+ }
407
+ });
408
+
409
+ // PATCH the dismissal. Body: { dismissed: <bool> | null } — null clears the
410
+ // override so the nudge can come back (the undismiss path). Sibling behavior
411
+ // keys are preserved by applyPatch.
412
+ router.patch('/me/profile-nudge', api.requireBuilder, async (req, res) => {
413
+ try {
414
+ const v = profileNudgePrefs.validatePatch(req.body || {});
415
+ if (!v.ok) return res.fail('invalid_patch', 422, { errors: v.errors });
416
+ const existing = await db.getBuilderBehaviorPrefs(req.builder.id);
417
+ const next = profileNudgePrefs.applyPatch(existing, v.patch);
418
+ const saved = await db.setBuilderBehaviorPrefs(req.builder.id, next);
419
+ const builder = await api.getBuilderById(req.builder.id);
420
+ res.json(profileNudgePrefs.describeForApi(saved, {
421
+ preferredDisciplines: builder && builder.preferred_disciplines,
422
+ }));
423
+ } catch (err) {
424
+ log.error('PATCH /me/profile-nudge', err);
425
+ res.fail('write_failed', { status: 500, message: 'internal error' });
426
+ }
427
+ });
428
+
381
429
  // Per-builder DISPLAY NAME (task 1669). The free label shown next to a
382
430
  // builder's work in the hall (header, leaderboard, work feed — all already read
383
431
  // display_name, so a DB update propagates). Seeded once from GitHub at account
@@ -500,6 +500,10 @@ function createClient(opts = {}) {
500
500
  patchMeModelAllocation: (args) => request("PATCH", "/me/model-allocation", { hasBody: true }, args),
501
501
  // PATCH /me/profile — rank: any-builder — PATCH /me/profile
502
502
  patchMeProfile: (args) => request("PATCH", "/me/profile", { hasBody: true }, args),
503
+ // GET /me/profile-nudge — rank: any-builder — GET /me/profile-nudge
504
+ getMeProfileNudge: (args) => request("GET", "/me/profile-nudge", { hasBody: false }, args),
505
+ // PATCH /me/profile-nudge — rank: any-builder — PATCH /me/profile-nudge
506
+ patchMeProfileNudge: (args) => request("PATCH", "/me/profile-nudge", { hasBody: true }, args),
503
507
  // GET /me/recent-ships — rank: any-builder — GET /me/recent-ships
504
508
  getMeRecentShips: (args) => request("GET", "/me/recent-ships", { hasBody: false }, args),
505
509
  // GET /me/render — rank: any-builder — GET /me/render
@@ -273,6 +273,13 @@
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
275
  '<div id="attention-goals"></div>' +
276
+ // task 1002802 (P06): "Complete your profile" — a DISMISSIBLE nudge when
277
+ // bio or preferred_disciplines are empty. Rendered by builders.js (which
278
+ // already holds the /me state the condition reads), exactly like the
279
+ // ideas slice. Placement rule: an action awaiting the builder is an inbox
280
+ // LINE, never a new Home card — that is why the rejected profile wizard
281
+ // (task 1002783) became this.
282
+ '<div id="attention-profile"></div>' +
276
283
  '<div id="attention-ideas"></div>' +
277
284
  // task 1003066 (BV1.R05): routed filings that SOFT-LANDED — a reported bug
278
285
  // now sitting at 'backlog' in a real goal, one Metic+ nod from claimable.
@@ -284,6 +291,9 @@
284
291
  host.addEventListener('click', onClick);
285
292
  load();
286
293
  loadNodQueue();
294
+ // task 1002802 (P06): the profile slice lives in its own file (builders.js is
295
+ // over the size budget); this card owns its div, so it boots it here.
296
+ window.OTBProfileNudge?.load();
287
297
  }
288
298
 
289
299
  // The card's shared visibility contract: any slice renderer calls refresh()
@@ -123,6 +123,10 @@
123
123
  hall-widget tags the server injects still anchor on builders.js's tag
124
124
  below (it matches by filename), so they land between the two. -->
125
125
  <script src="/builders/hall-render.js?v=2026-08-24-hallrender1" defer></script>
126
+ <!-- task 1002802 (P06): the profile-completion nudge slice. Exposes
127
+ window.OTBProfileNudge; goal-inbox.js's mount() calls it once it has
128
+ created the #attention-profile div, so load order is not load-bearing. -->
129
+ <script src="/builders/profile-nudge.js?v=2026-08-25-profile-nudge" defer></script>
126
130
  <script src="/builders/builders.js?v=2026-06-27-r75" defer></script>
127
131
  <script src="/builders/copy-on-click.js?v=2026-06-01-copy-1" defer></script>
128
132
  </body>
@@ -0,0 +1,122 @@
1
+ // profile-nudge.js — the "Complete your profile" slice of the hall's
2
+ // "Needs your attention" card (task 1002802 / P06, goal 1000045).
3
+ //
4
+ // Its own file rather than a block inside builders.js: the slice needs no state
5
+ // from that file (both its inputs come from its own two fetches), and builders.js
6
+ // is already far over the 1,500-line budget the quality ratchets enforce.
7
+ //
8
+ // goal-inbox.js owns the CARD (id 'attention-scroll') and creates this slice's
9
+ // div (#attention-profile); card visibility + the headline count derive from
10
+ // every slice via window.OTBAttention.refresh().
11
+ (function () {
12
+ 'use strict';
13
+ if (!window.OTB) return;
14
+ const { escapeHtml } = window.OTB;
15
+ const API = '/api/bongos';
16
+ // Result-mode client: a 401/404 must be inspectable control flow, never a throw
17
+ // that breaks a sibling slice (the goal-inbox.js pattern).
18
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
19
+
20
+ async function getJSON(path) {
21
+ const res = await api.request('GET', path);
22
+ if (!res.ok) { const e = new Error(`${path} → ${res.status}`); e.status = res.status; throw e; }
23
+ return res.data;
24
+ }
25
+ async function patchJSON(path, body) {
26
+ const res = await api.request('PATCH', path, { body: body || {} });
27
+ if (!res.ok) { const e = new Error(`${path} → ${res.status}`); e.status = res.status; throw e; }
28
+ return res.data;
29
+ }
30
+
31
+
32
+ // Renders the PROFILE slice of the "Needs your attention" card. goal-inbox.js
33
+ // owns the card (id 'attention-scroll') + the slice div; visibility and the
34
+ // headline count derive from every slice via OTBAttention.
35
+ //
36
+ // WHY A LINE AND NOT A CARD OR A WIZARD: spec Decision 2 rejected a
37
+ // profile-setup wizard (profiles are auto-created and enriched
38
+ // progressively; an interstitial after sign-in adds friction at the drop-off
39
+ // point). The hall's placement rule then puts an action awaiting the builder
40
+ // in the inbox as a LINE, never a new Home card.
41
+ //
42
+ // The two inputs live in different places — preferred_disciplines on the
43
+ // builder row (already in the /me payload this file holds) and bio on the
44
+ // platform-identity account (GET /me/cross-project) — so the SHOW decision is
45
+ // composed here. The server owns only the dismissal flag.
46
+ // Uses .ginbox__item so OTBAttention.refresh() counts it with no selector change.
47
+ function renderProfileNudge(state) {
48
+ const box = document.getElementById('attention-profile');
49
+ if (!box) return;
50
+ if (!state || !state.show) {
51
+ box.innerHTML = '';
52
+ window.OTBAttention?.refresh();
53
+ return;
54
+ }
55
+ // Name what is actually missing: "add a bio" is actionable, "complete your
56
+ // profile" is a chore of unknown size.
57
+ const missing = Array.isArray(state.missing) ? state.missing : [];
58
+ const parts = [];
59
+ if (missing.includes('bio')) parts.push('a short bio');
60
+ if (missing.includes('disciplines')) parts.push('the kinds of work you want');
61
+ const what = parts.length === 2 ? `${parts[0]} and ${parts[1]}` : (parts[0] || 'a detail or two');
62
+ box.innerHTML = '<h3 class="ginbox__sec-h">Complete your profile</h3>'
63
+ + '<ul class="ginbox">'
64
+ + '<li class="ginbox__item">'
65
+ // .ginbox__body / .ginbox__actions are the classes style.css actually
66
+ // defines (the body is the flex column that stacks label over sub-line).
67
+ + '<div class="ginbox__body">'
68
+ + `<a class="ginbox__goal" href="/settings">Add ${escapeHtml(what)}</a>`
69
+ + '<span class="ginbox__sub">Shown on your public profile — it helps others know what you work on.</span>'
70
+ + '</div>'
71
+ + '<div class="ginbox__actions">'
72
+ + '<button class="btn-ghost" type="button" data-profile-nudge-dismiss="1">Dismiss</button>'
73
+ + '</div>'
74
+ + '</li></ul>';
75
+ window.OTBAttention?.refresh();
76
+ }
77
+
78
+ // Fetch the dismissal flag + the bio in parallel, then compose. Any failure
79
+ // renders NOTHING (a nudge is the most skippable thing on the page — it must
80
+ // never surface an error card or break a sibling slice).
81
+ async function loadProfileNudge() {
82
+ try {
83
+ const [nudge, cross] = await Promise.all([
84
+ getJSON(`${API}/me/profile-nudge`),
85
+ getJSON(`${API}/me/cross-project`).catch(() => null),
86
+ ]);
87
+ if (!nudge) return;
88
+ // The server computed `missing` from preferred_disciplines only (bio is not
89
+ // its to read — different module). Fold the bio in here.
90
+ const missing = Array.isArray(nudge.missing) ? nudge.missing.slice() : [];
91
+ const bio = cross && typeof cross.bio === 'string' ? cross.bio : '';
92
+ const bioMissing = bio.trim() === '';
93
+ // A null cross-project (older server, or a principal with no platform
94
+ // identity) means "cannot tell" — do not invent a missing bio from it.
95
+ if (bioMissing && cross && !missing.includes('bio')) missing.push('bio');
96
+ if (!bioMissing) { const i = missing.indexOf('bio'); if (i !== -1) missing.splice(i, 1); }
97
+ renderProfileNudge({ ...nudge, missing, show: !nudge.dismissed && missing.length > 0 });
98
+ } catch (_) {
99
+ /* signed out, older server, or a hiccup: the nudge simply does not appear */
100
+ }
101
+ }
102
+
103
+ // task 1002802 (P06): dismiss the profile nudge. Delegated for the same reason
104
+ // as the triage verb above — the slice re-renders. Optimistic: the line goes
105
+ // away immediately (a dismissal that appears to fail is worse than one that
106
+ // silently retries), and the server flag makes it stay gone on every device.
107
+ document.addEventListener('click', (e) => {
108
+ const btn = e.target.closest('[data-profile-nudge-dismiss]');
109
+ if (!btn) return;
110
+ btn.disabled = true;
111
+ renderProfileNudge({ show: false });
112
+ patchJSON(`${API}/me/profile-nudge`, { dismissed: true })
113
+ .catch(() => loadProfileNudge()); // write failed → put the line back, honestly
114
+ });
115
+
116
+ // The CARD OWNER boots its slices: goal-inbox.js's mount() creates
117
+ // #attention-profile and then calls this, exactly as it calls its own load()
118
+ // and loadNodQueue(). No DOMContentLoaded race and no polling for the div —
119
+ // the widget registry mounts asynchronously after /me resolves, so a
120
+ // load-time call would reliably find nothing.
121
+ window.OTBProfileNudge = { load: loadProfileNudge, render: renderProfileNudge };
122
+ })();
@@ -134,6 +134,33 @@ async function setGoalCategory({ goalId, categoryId }, deps = {}) {
134
134
  return rows[0] ?? null;
135
135
  }
136
136
 
137
+ // -------------------------------------------------------------------------
138
+ // The advisory (task 1003277)
139
+ // -------------------------------------------------------------------------
140
+
141
+ // adviseCategory — PURE, FAIL-OPEN (the goal-advisory.js posture: this is advice
142
+ // appended to a request that ALREADY SUCCEEDED, so a wrong guess must nag nobody).
143
+ // Returns null when the goal is categorised, or the advisory body when it is not.
144
+ //
145
+ // The criterion is explicit about what an uncategorised goal IS: "Claude assigns
146
+ // where it can; where it cannot, the human is prompted to create a new category —
147
+ // that failure is a designed prompt, not an error." So the advisory carries the
148
+ // live SET (so the client can offer real choices, never hard-coded labels) and
149
+ // names the create endpoint (so the prompt has somewhere to write into). It never
150
+ // escalates past advice — the moment a missing category can refuse a write, the
151
+ // label has become a gate (ADR 0192).
152
+ function adviseCategory({ categoryId, categories = [] } = {}) {
153
+ if (categoryId != null) return null;
154
+ return {
155
+ reason: 'uncategorised',
156
+ message: 'This goal has no human category yet. A category orients a person scanning the board '
157
+ + '(it gates nothing — ADR 0192). Pick one from the set below via PATCH /goals/:id/category, '
158
+ + 'or — if none fits — ask the human to name a new one and create it with POST /work-categories: '
159
+ + 'that failure is a designed prompt, not an error.',
160
+ categories: categories.map((c) => ({ id: String(c.id), slug: c.slug, label: c.label })),
161
+ };
162
+ }
163
+
137
164
  // -------------------------------------------------------------------------
138
165
  // Decoration
139
166
  // -------------------------------------------------------------------------
@@ -263,6 +290,7 @@ module.exports = {
263
290
  isValidSlug,
264
291
  listCategories,
265
292
  getCategory,
293
+ adviseCategory,
266
294
  createCategory,
267
295
  setGoalCategory,
268
296
  decorateGoalCategory,
@@ -129,7 +129,16 @@ module.exports = function buildGoalsRouter() {
129
129
  if (succeedsId != null) {
130
130
  await db.setGoalSuccessor({ goalId: succeedsId, successorId: goal.id });
131
131
  }
132
- return res.status(201).json({ ok: true, goal, succeeds_goal_id: succeedsId });
132
+ // task 1003277 — the category advisory, the goal_advisory precedent: advice
133
+ // on a request that already succeeded, null when the goal has a category.
134
+ // Best-effort: a failed set read must not turn advice into a 500.
135
+ let category_advisory = null;
136
+ if (categoryId === null) {
137
+ try {
138
+ category_advisory = goalCategory.adviseCategory({ categoryId, categories: await goalCategory.listCategories() });
139
+ } catch (_) { /* fail-open — advice only */ }
140
+ }
141
+ return res.status(201).json({ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory });
133
142
  } catch (err) {
134
143
  log.error('[gds] POST /goals', err);
135
144
  return res.fail('goal_create_failed', { status: 500, message: 'internal error' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.372",
3
+ "version": "1.19.374",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.372",
9
+ "version": "1.19.374",
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.372",
3
+ "version": "1.19.374",
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",
@@ -86,5 +86,45 @@ function isReleaseCommit(subject) {
86
86
  return RELEASE_COMMIT_RE.test(String(subject == null ? '' : subject));
87
87
  }
88
88
 
89
- module.exports = { bumpPatch, CONST_RE, RELEASE_COMMIT_RE, isReleaseCommit };
89
+ // laneReleasedVersions — every version THIS lane has announced it published, read
90
+ // from its own release commits (task 1003202).
91
+ //
92
+ // WHY THIS EXISTS. publish.yml used to answer "is HEAD's version already on the
93
+ // registry?" with `npm view @bongos/core@<v>`. That needs a credential:
94
+ // @bongos/core is PRIVATE, so an unauthenticated read is a flat E404, not a
95
+ // "no". Moving the lane to OIDC trusted publishing removes the token — and OIDC
96
+ // only authenticates `npm publish`, nothing else — so the old check would have
97
+ // silently reported "not taken" on EVERY run, skipped the auto-bump, and then
98
+ // collided on the already-published version. It would have looked fine for
99
+ // exactly one release.
100
+ //
101
+ // The answer is already in the repo. Every version this lane ships is announced
102
+ // by a `release: core X.Y.Z — CI auto-patch carrier` commit it writes itself, and
103
+ // RELEASE_COMMIT_RE above is that contract. So the registry is not the only place
104
+ // that knows; git knows too, needs no auth, and is the same source the cascade
105
+ // terminator already trusts.
106
+ //
107
+ // Deliberately shares RELEASE_COMMIT_RE with isReleaseCommit, so a HUMAN release
108
+ // commit ("release: core 1.19.2 — carrier for the init-blocker fixes") is NOT
109
+ // counted here either — that version is new and still needs publishing, which is
110
+ // exactly the distinction the constant was written to make.
111
+ //
112
+ // BOTH failure directions are safe, which is what makes this acceptable on a
113
+ // release-critical lane:
114
+ // • commit written, publish then failed → version looks taken, next run bumps
115
+ // past it. A gap in the version sequence; harmless.
116
+ // • published, commit push then failed → version looks free, the publish
117
+ // retries and npm refuses with EPUBLISHCONFLICT. Loud, and nothing wrong ships.
118
+ function laneReleasedVersions(subjects = []) {
119
+ const out = new Set();
120
+ for (const subject of subjects) {
121
+ const line = String(subject == null ? '' : subject);
122
+ if (!RELEASE_COMMIT_RE.test(line)) continue;
123
+ const m = /^release: core (\d+\.\d+\.\d+) /.exec(line);
124
+ if (m) out.add(m[1]);
125
+ }
126
+ return out;
127
+ }
128
+
129
+ module.exports = { bumpPatch, CONST_RE, RELEASE_COMMIT_RE, isReleaseCommit, laneReleasedVersions };
90
130
  if (require.main === module) main();
@@ -41,6 +41,7 @@ const WRITE_VALIDATION_EXEMPT = new Set([
41
41
  'PATCH /me/model-allocation', // per-slot tiers — modelAllocPrefs.validatePatch
42
42
  'PATCH /me/render', // { widgets: <bool>|null } — renderPrefs.validatePatch
43
43
  'PATCH /me/display-name', // { display_name } — displayName.validate
44
+ 'PATCH /me/profile-nudge', // { dismissed: <bool>|null } — profileNudgePrefs.validatePatch
44
45
  ]);
45
46
 
46
47
  // KNOWN GAPS, NOT EXEMPTIONS (task 1002551 / BV1.R109b → tracked by task 1002552).