@bongos/core 1.19.373 → 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.
@@ -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
+ })();
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.373",
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.373",
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.373",
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",
@@ -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).
@@ -406,6 +406,80 @@ function rerunGateReviewIfStale(branch, headSha) {
406
406
  return ghArgvOk(['run', 'rerun', String(id)]);
407
407
  }
408
408
 
409
+ // ---------- why the push failed, and whether retrying can ever help ----------
410
+ //
411
+ // A failed `git push` already parks the task at `confirmed` and opens no PR,
412
+ // which is correct. What was wrong (task 1003293) was the ADVICE: "Fix the push
413
+ // and re-ship" is useless for the most common cause, because re-shipping fails
414
+ // identically every time and the reconciler cannot heal a branch that was never
415
+ // pushed. The task then sits at `confirmed` with its code on one laptop, looking
416
+ // like an ordinary pending land. That happened twice in one session — both times
417
+ // on a commit touching .github/workflows/, both times invisible until someone
418
+ // checked git instead of the log.
419
+ //
420
+ // So the distinction that matters is TERMINAL vs transient. A terminal failure
421
+ // must name the actual remedy; a transient one may honestly say "re-ship".
422
+ // `argvOk` runs the push with stdio:'inherit', so git's own message reaches the
423
+ // log but nothing ever reads it — these patterns read it.
424
+ const PUSH_DIAGNOSES = [
425
+ {
426
+ id: 'workflow_scope',
427
+ // GitHub's wording: "refusing to allow an OAuth App to create or update
428
+ // workflow `.github/workflows/x.yml` without `workflow` scope".
429
+ match: /without .{0,3}workflow.{0,3} scope|refusing to allow (?:an?|the) \S+ (?:App|integration)/i,
430
+ terminal: true,
431
+ why: 'this branch changes a file under .github/workflows/, and the credential git used has no `workflow` scope — GitHub refuses that push regardless of how often it is retried',
432
+ fix: 'push over SSH once, then re-run the ship:\n git push git@github.com:<owner>/<repo>.git <branch>\n (or re-authenticate the CLI with the `workflow` scope: gh auth refresh -h github.com -s workflow)',
433
+ },
434
+ {
435
+ id: 'protected_branch',
436
+ match: /GH006|protected branch|required status check/i,
437
+ terminal: true,
438
+ why: 'branch protection refused the push outright',
439
+ fix: 'land through a pull request instead of pushing directly, or have an admin adjust the rule',
440
+ },
441
+ {
442
+ id: 'auth',
443
+ match: /Authentication failed|could not read Username|Permission denied \(publickey\)|remote: Invalid username or password|403 Forbidden/i,
444
+ terminal: true,
445
+ why: 'git could not authenticate to the remote at all',
446
+ fix: 'restore the credential (gh auth login, or an SSH key the remote accepts), then re-run the ship',
447
+ },
448
+ {
449
+ id: 'stale_branch',
450
+ match: /non-fast-forward|fetch first|behind its remote counterpart|Updates were rejected/i,
451
+ terminal: false,
452
+ why: 'the remote branch has commits this checkout does not',
453
+ fix: 'git fetch && git rebase origin/<branch>, then re-run the ship',
454
+ },
455
+ ];
456
+
457
+ // Pure: classify a failed push from git's stderr. Exported + tested.
458
+ // An unrecognised failure is reported as NON-terminal on purpose — "try again"
459
+ // is the safe default when we do not know, and a wrong "this can never work"
460
+ // would send someone hunting a problem that does not exist.
461
+ function pushDiagnosis(stderr) {
462
+ const text = String(stderr == null ? '' : stderr);
463
+ for (const d of PUSH_DIAGNOSES) {
464
+ if (d.match.test(text)) return { id: d.id, terminal: d.terminal, why: d.why, fix: d.fix };
465
+ }
466
+ return { id: 'unknown', terminal: false, why: 'the push failed for a reason this lane does not recognise', fix: 'read git\'s message above, resolve it, then re-run the ship' };
467
+ }
468
+
469
+ // Push, capturing stderr so it can be diagnosed — and echoing it so the log still
470
+ // shows exactly what git said, which stdio:'inherit' gave us for free.
471
+ function pushCapturing(branch, deps = {}) {
472
+ const exec = deps.execFileSync || require('node:child_process').execFileSync;
473
+ try {
474
+ exec('git', ['push', 'origin', branch], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
475
+ return { ok: true, stderr: '' };
476
+ } catch (e) {
477
+ const stderr = [e && e.stderr, e && e.stdout].filter(Boolean).join('\n');
478
+ if (stderr) process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
479
+ return { ok: false, stderr };
480
+ }
481
+ }
482
+
409
483
  // ciLandLocal — today's local path: this machine pushes the branch + opens the PR
410
484
  // + enables auto-merge via git + gh. async since task 1169 — it asks the server to
411
485
  // post the gate-author-trust attestation between the push and the PR open.
@@ -413,9 +487,24 @@ async function ciLandLocal(taskId, branch, valueSummary) {
413
487
  const headSha = gitOk('git rev-parse HEAD');
414
488
 
415
489
  console.log(` pushing ${branch} to origin...`);
416
- if (!gitArgvOk(['push', 'origin', branch])) {
417
- console.log(' ci-land: push failed; task stays at confirmed. Fix the push and re-ship.');
418
- return landBailed(`could not push ${branch} to origin`, `node scripts/gds/ship.js ${taskId}`);
490
+ const pushed = pushCapturing(branch);
491
+ if (!pushed.ok) {
492
+ const d = pushDiagnosis(pushed.stderr);
493
+ console.log(` ci-land: push FAILED — ${d.why}.`);
494
+ console.log(` ci-land: nothing was pushed and no PR exists; task stays at confirmed.`);
495
+ if (d.terminal) {
496
+ // The important line. Re-shipping cannot fix this, and the reconciler
497
+ // cannot heal a branch that never reached the remote — so do not say
498
+ // "re-ship" and leave the work stranded on one machine.
499
+ console.log(' ci-land: RE-SHIPPING WILL FAIL THE SAME WAY. Do this instead:');
500
+ console.log(` ${d.fix}`);
501
+ } else {
502
+ console.log(` ci-land: to retry — ${d.fix}`);
503
+ }
504
+ return landBailed(
505
+ `could not push ${branch} to origin (${d.id})`,
506
+ d.terminal ? d.fix.split('\n')[0] : `node scripts/gds/ship.js ${taskId}`,
507
+ );
419
508
  }
420
509
 
421
510
  // task 1169 (ADR 0058): attest the author's trust tier on the head SHA BEFORE
@@ -545,6 +634,8 @@ function pollCiDeploy(taskId, branch, headSha) {
545
634
 
546
635
  module.exports = {
547
636
  localHistoryRewritten, // task 1003221 — the append-only discriminator
637
+ pushDiagnosis, // task 1003293 — a terminal push failure must not advise a retry
638
+ PUSH_DIAGNOSES,
548
639
  ciLand,
549
640
  classifyLandOutcome,
550
641
  formatPublishFailure,
package/src/module-api.js CHANGED
@@ -1176,8 +1176,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1176
1176
  // landed since 1.19.371 with no explicit bump. run 32905166786. (task 1002620)
1177
1177
  // 1.19.373 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1178
1178
  // landed since 1.19.372 with no explicit bump. run 32906118616. (task 1002620)
1179
+ // 1.19.374 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1180
+ // landed since 1.19.373 with no explicit bump. run 32906477349. (task 1002620)
1179
1181
  // ---------------------------------------------------------------------------
1180
- const CORE_VERSION = '1.19.373'; // CI auto-patch carrier (ADR 0161); see changelog above
1182
+ const CORE_VERSION = '1.19.374'; // CI auto-patch carrier (ADR 0161); see changelog above
1181
1183
 
1182
1184
  // A namespaced logger so a module's log lines are attributable + consistent.
1183
1185
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,97 @@
1
+ // tests/profile_nudge_prefs.mjs — the "Complete your profile" nudge shaper
2
+ // (task 1002802 / P06, goal 1000045).
3
+ //
4
+ // The nudge replaced the profile-setup wizard spec Decision 2 rejected: a
5
+ // DISMISSIBLE line item in the hall's needs-attention inbox, shown when bio or
6
+ // preferred_disciplines are empty. These pin the pure decision layer — what
7
+ // counts as missing, when the line shows, and that a dismissal is durable and
8
+ // reversible without disturbing the sibling knobs sharing behavior_prefs.
9
+ //
10
+ // Run: node --test tests/profile_nudge_prefs.mjs
11
+
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const p = require('../modules/builder-settings/profile-nudge-prefs.js');
18
+
19
+ test('missingProfileFields: empty, whitespace, and absent all count as missing', () => {
20
+ assert.deepEqual(p.missingProfileFields({ bio: '', preferredDisciplines: [] }), ['bio', 'disciplines']);
21
+ assert.deepEqual(p.missingProfileFields({ bio: ' \n\t ', preferredDisciplines: [] }), ['bio', 'disciplines']);
22
+ assert.deepEqual(p.missingProfileFields({}), ['bio', 'disciplines']);
23
+ assert.deepEqual(p.missingProfileFields({ bio: null, preferredDisciplines: null }), ['bio', 'disciplines']);
24
+ });
25
+
26
+ test('missingProfileFields: a real value on either side drops only that entry', () => {
27
+ assert.deepEqual(p.missingProfileFields({ bio: 'I build things', preferredDisciplines: [] }), ['disciplines']);
28
+ assert.deepEqual(p.missingProfileFields({ bio: '', preferredDisciplines: ['engineer'] }), ['bio']);
29
+ assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: ['engineer'] }), []);
30
+ });
31
+
32
+ test('missingProfileFields: a non-array disciplines value is missing, not a crash', () => {
33
+ assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: 'engineer' }), ['disciplines']);
34
+ assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: {} }), ['disciplines']);
35
+ });
36
+
37
+ test('nudgeDismissed: defaults to NOT dismissed; only a real boolean overrides', () => {
38
+ assert.equal(p.nudgeDismissed({}), false);
39
+ assert.equal(p.nudgeDismissed(null), false);
40
+ assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: true }), true);
41
+ assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: false }), false);
42
+ // Garbage in the blob must not read as dismissed — that would silence the
43
+ // nudge for a builder who never dismissed it.
44
+ assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: 'yes' }), false);
45
+ assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: 1 }), false);
46
+ });
47
+
48
+ test('shouldNudge: shows only when something is missing AND not dismissed', () => {
49
+ const missing = { bio: '', preferredDisciplines: [] };
50
+ const complete = { bio: 'x', preferredDisciplines: ['engineer'] };
51
+ assert.equal(p.shouldNudge({ behaviorPrefs: {}, ...missing }), true);
52
+ assert.equal(p.shouldNudge({ behaviorPrefs: { profile_nudge_dismissed: true }, ...missing }), false);
53
+ assert.equal(p.shouldNudge({ behaviorPrefs: {}, ...complete }), false);
54
+ assert.equal(p.shouldNudge({ behaviorPrefs: { profile_nudge_dismissed: true }, ...complete }), false);
55
+ });
56
+
57
+ test('describeForApi: reports what is missing even while dismissed (show is the gate)', () => {
58
+ const d = p.describeForApi({ profile_nudge_dismissed: true }, { bio: '', preferredDisciplines: [] });
59
+ assert.equal(d.dismissed, true);
60
+ assert.equal(d.show, false, 'a dismissed nudge never shows');
61
+ assert.deepEqual(d.missing, ['bio', 'disciplines'], 'but the API still says what is empty');
62
+ assert.equal(d.override, true);
63
+ assert.equal(d.default_dismissed, false);
64
+ });
65
+
66
+ test('validatePatch: accepts dismissed true/false/null, refuses everything else', () => {
67
+ assert.deepEqual(p.validatePatch({ dismissed: true }), { ok: true, patch: { profile_nudge_dismissed: true } });
68
+ assert.deepEqual(p.validatePatch({ dismissed: false }), { ok: true, patch: { profile_nudge_dismissed: false } });
69
+ assert.deepEqual(p.validatePatch({ dismissed: null }), { ok: true, patch: { profile_nudge_dismissed: null } });
70
+ assert.deepEqual(p.validatePatch({}), { ok: true, patch: {} }, 'empty body is a valid no-op');
71
+ assert.equal(p.validatePatch({ dismissed: 'yes' }).ok, false);
72
+ assert.equal(p.validatePatch({ nope: true }).ok, false);
73
+ assert.equal(p.validatePatch(null).ok, false);
74
+ assert.equal(p.validatePatch([]).ok, false);
75
+ });
76
+
77
+ test('applyPatch: null clears the key; sibling behavior knobs are never disturbed', () => {
78
+ const existing = { wandering: 'focused', widgets: false, interaction: { technical_level: 'engineer' } };
79
+ const set = p.applyPatch(existing, { profile_nudge_dismissed: true });
80
+ assert.equal(set.profile_nudge_dismissed, true);
81
+ assert.equal(set.wandering, 'focused');
82
+ assert.equal(set.widgets, false);
83
+ assert.deepEqual(set.interaction, { technical_level: 'engineer' });
84
+ // Clearing removes the key entirely (back to the sparse default) and still
85
+ // leaves the siblings alone — the undismiss path.
86
+ const cleared = p.applyPatch(set, { profile_nudge_dismissed: null });
87
+ assert.equal('profile_nudge_dismissed' in cleared, false);
88
+ assert.equal(cleared.wandering, 'focused');
89
+ assert.equal(p.nudgeDismissed(cleared), false, 'cleared → the nudge can return');
90
+ // Pure: the input blob is not mutated.
91
+ assert.equal('profile_nudge_dismissed' in existing, false);
92
+ });
93
+
94
+ test('the reserved key is the documented one (behavior_prefs is shared)', () => {
95
+ assert.equal(p.RESERVED_KEY, 'profile_nudge_dismissed');
96
+ assert.equal(p.DEFAULT_DISMISSED, false);
97
+ });
@@ -0,0 +1,78 @@
1
+ // tests/ship_push_diagnosis.mjs — a terminal push failure must not advise a retry
2
+ // (task 1003293).
3
+ //
4
+ // A failed `git push` in ciLandLocal already parks the task at `confirmed` and
5
+ // opens no PR, which is correct. The ADVICE was the defect: "Fix the push and
6
+ // re-ship" cannot work for the most common cause — a commit touching
7
+ // .github/workflows/ pushed with a credential lacking GitHub's `workflow` scope.
8
+ // Re-shipping fails identically, and the reconciler cannot heal a branch that
9
+ // never reached the remote, so the task sits at `confirmed` looking like an
10
+ // ordinary pending land while its code exists on exactly one machine. That
11
+ // happened twice in one session.
12
+ //
13
+ // These tests pin the distinction the fix turns on: TERMINAL (retrying is
14
+ // pointless — say what actually resolves it) vs transient (retrying is the
15
+ // answer).
16
+ import assert from 'node:assert/strict';
17
+ import { test } from 'node:test';
18
+ import { createRequire } from 'node:module';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const { pushDiagnosis, PUSH_DIAGNOSES } = require(path.join(ROOT, 'scripts', 'gds', 'ship-land.js'));
25
+
26
+ test('THE case that stranded two tasks: the workflow scope rejection', () => {
27
+ // GitHub's real wording, verbatim from the run that stranded task 1003283.
28
+ const d = pushDiagnosis(
29
+ 'remote: error: refusing to allow an OAuth App to create or update workflow '
30
+ + '`.github/workflows/publish.yml` without `workflow` scope\n'
31
+ + 'To https://github.com/owner/repo.git\n ! [remote rejected] branch -> branch');
32
+ assert.equal(d.id, 'workflow_scope');
33
+ assert.equal(d.terminal, true, 'retrying this can never succeed — saying "re-ship" is what stranded the work');
34
+ assert.match(d.fix, /ssh|workflow/i, 'the advice must name the remedy that actually works');
35
+ });
36
+
37
+ test('branch protection and a dead credential are terminal too', () => {
38
+ assert.equal(pushDiagnosis('remote: error: GH006: Protected branch update failed').terminal, true);
39
+ assert.equal(pushDiagnosis('fatal: Authentication failed for https://github.com/o/r.git/').terminal, true);
40
+ assert.equal(pushDiagnosis('git@github.com: Permission denied (publickey).').terminal, true);
41
+ });
42
+
43
+ test('a stale branch IS retryable — the honest "re-ship" case', () => {
44
+ const d = pushDiagnosis('error: failed to push some refs\nUpdates were rejected because the remote contains work that you do not have locally');
45
+ assert.equal(d.id, 'stale_branch');
46
+ assert.equal(d.terminal, false);
47
+ });
48
+
49
+ test('an unrecognised failure defaults to RETRYABLE, never to terminal', () => {
50
+ // The safe direction. A wrong "this can never work" sends someone hunting a
51
+ // problem that does not exist; a wrong "try again" costs one command.
52
+ const d = pushDiagnosis('fatal: the remote end hung up unexpectedly');
53
+ assert.equal(d.id, 'unknown');
54
+ assert.equal(d.terminal, false);
55
+ assert.equal(pushDiagnosis('').terminal, false);
56
+ assert.equal(pushDiagnosis(null).terminal, false);
57
+ assert.equal(pushDiagnosis(undefined).terminal, false);
58
+ });
59
+
60
+ test('every diagnosis carries both a cause and a remedy', () => {
61
+ // A classification with no `fix` is just a nicer way of saying "it broke",
62
+ // which is the state this replaced.
63
+ for (const d of PUSH_DIAGNOSES) {
64
+ assert.ok(d.id && d.why && d.fix, `incomplete diagnosis: ${JSON.stringify(d)}`);
65
+ assert.ok(d.why.length > 20, `${d.id}: "why" must explain, not label`);
66
+ assert.equal(typeof d.terminal, 'boolean');
67
+ }
68
+ const fallback = pushDiagnosis('anything at all');
69
+ assert.ok(fallback.why && fallback.fix);
70
+ });
71
+
72
+ test('the patterns do not collide — the first match is the RIGHT match', () => {
73
+ // A workflow-scope rejection also contains "error:" and often "403"; if the
74
+ // auth pattern matched it first, the advice would send someone to re-login
75
+ // instead of to SSH, and the push would fail again.
76
+ const workflowMsg = 'remote: error: refusing to allow an OAuth App to create or update workflow `.github/workflows/x.yml` without `workflow` scope';
77
+ assert.equal(pushDiagnosis(workflowMsg).id, 'workflow_scope');
78
+ });
@@ -343,3 +343,41 @@ test('playerFor never throws and returns null or a [cmd, args] pair', () => {
343
343
  assert.ok(Array.isArray(got[1]));
344
344
  }
345
345
  });
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // readPrefs honours the path it was GIVEN (task 1003293).
349
+ //
350
+ // The legacy-path fallback used to fire for any unreadable path, so a caller
351
+ // naming an explicit file silently received a different one — the user's real
352
+ // prefs. That made the two fail-open tests above pass in CI (no prefs on a
353
+ // runner) and fail on any machine where the builder had saved sound settings:
354
+ // green where nobody looks, red where everybody does. The fallback is still
355
+ // right for the DEFAULT call; it is wrong the moment a caller names a file.
356
+ // ---------------------------------------------------------------------------
357
+
358
+ test('an explicit missing path fails open — it never substitutes another file', () => {
359
+ // The regression: write a legacy-shaped prefs file at the pre-rename location
360
+ // and prove an explicit path still ignores it. Skipped if the real file exists,
361
+ // because this test must never clobber a builder's own settings.
362
+ const legacy = player.LEGACY_PREFS_PATH;
363
+ if (!legacy || fs.existsSync(legacy)) {
364
+ // Nothing to plant, but the invariant is still checkable: an absent explicit
365
+ // path must yield the fail-open default, whatever the legacy file holds.
366
+ const p = player.readPrefs(path.join(os.tmpdir(), `otb-absent-${process.pid}.json`));
367
+ assert.equal(p.volume, 100);
368
+ assert.equal(p.enabled('ship'), true);
369
+ return;
370
+ }
371
+ fs.mkdirSync(path.dirname(legacy), { recursive: true });
372
+ fs.writeFileSync(legacy, JSON.stringify({ volume: 11, ship: false }));
373
+ try {
374
+ const p = player.readPrefs(path.join(os.tmpdir(), `otb-absent-${process.pid}.json`));
375
+ assert.equal(p.volume, 100, 'an explicit path must not inherit the legacy file volume');
376
+ assert.equal(p.enabled('ship'), true, 'an explicit path must not inherit legacy mutes');
377
+ // …while the DEFAULT call still honours it — the fallback is not removed.
378
+ const d = player.readPrefs();
379
+ assert.equal(d.volume, 11, 'the default path must still fall back to the legacy file');
380
+ } finally {
381
+ fs.unlinkSync(legacy);
382
+ }
383
+ });