@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.
- package/.bongos-core.json +54 -34
- package/.claude/skills/goal-create/SKILL.md +7 -4
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0161-publish-on-merge.md +1 -1
- package/docs/adr/0180-dependency-intake-and-drift-policy.md +6 -0
- package/docs/api/openapi.json +79 -4
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +6 -3
- package/docs/copy-registry.json +32 -5
- package/modules/builder-settings/profile-nudge-prefs.js +125 -0
- package/modules/builder-settings/routes/settings.js +48 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/goal-inbox.js +10 -0
- package/modules/hall-ui/public/index.html +4 -0
- package/modules/hall-ui/public/profile-nudge.js +122 -0
- package/modules/lifecycle/goal-category.js +28 -0
- package/modules/lifecycle/routes/goals.js +10 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/bump-version.js +41 -1
- package/scripts/gds/fitness-checks-write-validation.js +1 -0
- package/scripts/gds/ship-land.js +94 -3
- package/src/module-api.js +5 -1
- package/tests/bump_version.mjs +77 -1
- package/tests/goal_category.mjs +28 -0
- package/tests/profile_nudge_prefs.mjs +97 -0
- package/tests/ship_push_diagnosis.mjs +78 -0
- package/tests/sound_player.mjs +38 -0
package/docs/copy-registry.json
CHANGED
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
"generator": "scripts/gds/copy-inventory.js",
|
|
4
4
|
"goal": 1000074,
|
|
5
5
|
"counts": {
|
|
6
|
-
"strings":
|
|
7
|
-
"unique_ids":
|
|
8
|
-
"certain":
|
|
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":
|
|
16
|
-
"strings":
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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).
|