@bongos/core 1.19.371 → 1.19.373

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.
@@ -269,11 +269,42 @@
269
269
  return (data && typeof data === 'object') ? data : {};
270
270
  }
271
271
 
272
+ // task 1003276 (goal 1000072, criterion legibility-categories) — a work
273
+ // CATEGORY as a chip. Lives HERE, not in goals-lib.js, because three surfaces
274
+ // render it — the Goals page, the Tasks board and the task page — and
275
+ // dom-utils.js is the only script all three load. One renderer means the
276
+ // goal's chip and the task's inherited chip cannot drift.
277
+ //
278
+ // A LABEL and nothing more: no href, no pointer affordance. A per-category
279
+ // destination would be a navigation tier, and that is precisely how an
280
+ // advisory word becomes the second hierarchy in practice even when no code
281
+ // gates on it (ADR 0192 — a category authorises nothing).
282
+ //
283
+ // `inherited` marks a TASK's chip. A task stores no category; it reads its
284
+ // goal's through goal_id, and saying so out loud stops anyone hunting for a
285
+ // per-task editor that does not exist.
286
+ //
287
+ // Uncategorised renders NOTHING rather than an "Uncategorised" pill: most
288
+ // goals will carry one, and a board of grey placeholders reads worse than a
289
+ // board with gaps. The Category filter's "not categorised yet" option is how
290
+ // you find them.
291
+ function categoryChip(category, opts) {
292
+ const label = category && category.label ? String(category.label).trim() : '';
293
+ if (!label) return '';
294
+ const inherited = !!(opts && opts.inherited);
295
+ const title = inherited
296
+ ? `Category: ${label} — inherited from this task's goal (a task carries no category of its own)`
297
+ : `Category: ${label}`;
298
+ return `<span class="gr-cat${inherited ? ' gr-cat--inherited' : ''}" title="${escapeHtml(title)}">`
299
+ + `${escapeHtml(label)}${inherited ? '<i class="gr-cat__from" aria-hidden="true">via goal</i>' : ''}</span>`;
300
+ }
301
+
272
302
  window.OTB = {
273
303
  escapeHtml, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn,
274
304
  errCode, errMessage, errDetails, // task 1002462 — ADR 0116 envelope readers
275
305
  contributeSettingsPanel, settingsPanels,
276
306
  contributeHallWidget, hallWidgets, hallWidgetIds,
277
307
  splitCriterion,
308
+ categoryChip, // task 1003276 — shared by the Goals page, the Tasks board and the task page
278
309
  };
279
310
  })();
@@ -228,6 +228,34 @@
228
228
  }
229
229
  }
230
230
 
231
+ // task 1003276 — the Category dropdown. Unlike Version and Owner (derived from
232
+ // the loaded goals), the options come from the project's SET — GET
233
+ // /work-categories — so a category nobody has used yet is still offered, and a
234
+ // client never hard-codes the labels ("present in every builders hall on any
235
+ // project" only holds if the vocabulary travels over the wire). The 'none'
236
+ // option finds the uncategorised, which is the set this feature exists to
237
+ // shrink. Best-effort: a failed fetch leaves the control with just "— any —".
238
+ async function populateCategoryFilter() {
239
+ const sel = $('#f-goal-category');
240
+ if (!sel) return;
241
+ let cats = [];
242
+ try {
243
+ cats = (await getJSON(`${API}/work-categories`))?.categories || [];
244
+ } catch (_) { return; }
245
+ for (const c of cats) {
246
+ const opt = document.createElement('option');
247
+ opt.value = c.slug;
248
+ opt.textContent = c.label;
249
+ sel.appendChild(opt);
250
+ }
251
+ if (cats.length) {
252
+ const opt = document.createElement('option');
253
+ opt.value = 'none';
254
+ opt.textContent = 'not categorised yet';
255
+ sel.appendChild(opt);
256
+ }
257
+ }
258
+
231
259
  // Archon check for the detail view's manage/visibility affordances (the
232
260
  // server-side rank gates are the real enforcement). The task-1760 drag-reorder
233
261
  // affordance this once also served is retired — see the note where
@@ -797,6 +825,9 @@
797
825
  // task 1003135 — fire-and-forget: the Owner options fill when the (cached)
798
826
  // directory resolves; boot never waits on a label lookup.
799
827
  populateOwnerFilter().catch(() => {});
828
+ // task 1003276 — same fire-and-forget posture: boot never waits on the
829
+ // category vocabulary, and the board renders fine before it lands.
830
+ populateCategoryFilter().catch(() => {});
800
831
  // Prefetch details (ring + members on the cards). Sequentially-parallel,
801
832
  // failure-tolerant: a card without detail still renders.
802
833
  // task 1002439 — also fold in per-goal task-readiness rollups (one request
@@ -859,6 +890,9 @@
859
890
  { id: 'f-goal-status', label: 'Status', options: [{ value: '', label: '— any —' }, 'open', 'achieved', 'archived'] },
860
891
  { id: 'f-goal-mine', label: 'Membership', options: [{ value: '', label: 'All goals' }, { value: 'mine', label: 'My goals' }] },
861
892
  { id: 'f-goal-owner', label: 'Owner', options: [{ value: '', label: '— any —' }] },
893
+ // task 1003276 — filled from GET /work-categories after boot
894
+ // (populateCategoryFilter), the populateOwnerFilter pattern.
895
+ { id: 'f-goal-category', label: 'Category', options: [{ value: '', label: '— any —' }] },
862
896
  { id: 'f-goal-order',
863
897
  label: 'Sort',
864
898
  value: GOAL_SORTS[savedGoalSort] ? savedGoalSort : undefined,
@@ -19,7 +19,7 @@
19
19
 
20
20
  window.OTBGoalsRender = function createGoalsRender(ctx) {
21
21
  const { API, curLabel, getJSON, patchJSON, postJSON, sameId, selfId, state } = ctx;
22
- const { escapeHtml, $, fmtNum, fmtDate, toast, rankLabel } = window.OTB;
22
+ const { escapeHtml, $, fmtNum, fmtDate, toast, rankLabel, categoryChip } = window.OTB;
23
23
  const {
24
24
  priorityRank, touchesConflict, rankTagHtml, deHashRefs,
25
25
  creditsPerMinute, buildPromptForTask, friendlyBatchReason,
@@ -81,11 +81,18 @@
81
81
  const q = ($('#f-goals-search')?.value || '').trim().toLowerCase();
82
82
  const needle = q.replace(/^#/, '');
83
83
  const owner = $('#f-goal-owner')?.value || '';
84
+ // task 1003276 — the Category dropdown. Matches on the SLUG (the stable
85
+ // machine key) so re-wording a label never breaks a filter, and 'none' is
86
+ // the uncategorised set — which must be findable, because "what has nobody
87
+ // placed yet" is exactly the prompt this feature is built around.
88
+ const cat = $('#f-goal-category')?.value || '';
84
89
  const matchesSearch = (g) => !needle
85
90
  || String(g.id).includes(needle)
86
91
  || (g.title || '').toLowerCase().includes(needle);
87
92
  const matchesOwner = (g) => !owner
88
93
  || (owner === 'none' ? g.created_by == null : String(g.created_by) === owner);
94
+ const matchesCategory = (g) => !cat
95
+ || (cat === 'none' ? !g.category : (g.category?.slug === cat));
89
96
  const myId = selfId();
90
97
  const iAmMember = (g) => {
91
98
  const d = state.details.get(String(g.id));
@@ -96,6 +103,7 @@
96
103
  (!status || g.status === status) &&
97
104
  matchesSearch(g) &&
98
105
  matchesOwner(g) &&
106
+ matchesCategory(g) &&
99
107
  (!mineOnly || iAmMember(g)));
100
108
  }
101
109
  function goalStampHtml(g) {
@@ -127,10 +135,13 @@
127
135
  right = `<span class="gr-quiet">No tasks yet</span>`;
128
136
  }
129
137
  const metaHtml = `#${escapeHtml(String(g.id))} · ${escapeHtml(g.version_id || '')}${(achieved || g.status === 'open') ? '' : ' · ' + escapeHtml(g.status)}${goalStampHtml(g)}`;
130
- return { r, achieved, ringHtml, goalHref, right, metaHtml };
138
+ // task 1003276 — the category chip. Computed here with the rest of the facts
139
+ // so the row and the card (task 1003136) cannot drift on it.
140
+ const catHtml = categoryChip(g.category);
141
+ return { r, achieved, ringHtml, goalHref, right, metaHtml, catHtml };
131
142
  }
132
143
  function goalRowHtml(g) {
133
- const { r, achieved, ringHtml, goalHref, right, metaHtml } = goalRenderFacts(g, 44);
144
+ const { r, achieved, ringHtml, goalHref, right, metaHtml, catHtml } = goalRenderFacts(g, 44);
134
145
  // NOTE: a rollup row nests real links (title → goal, Claim → task overlay,
135
146
  // drill → goal), so the row itself is a <div>, not an <a> — nested anchors
136
147
  // are invalid. Only the explicit links navigate.
@@ -138,20 +149,20 @@
138
149
  <div class="gr-ringcol">${ringHtml}</div>
139
150
  <div class="gr-main">
140
151
  <a class="gr-title" href="${goalHref}"${subtitleAttr(g)}>${escapeHtml(g.title || 'Untitled goal')}</a>
141
- <div class="gr-meta">${metaHtml}</div>
152
+ <div class="gr-meta">${metaHtml}${catHtml}</div>
142
153
  ${achieved ? '' : goalRollLine(r)}
143
154
  </div>
144
155
  <div class="gr-right">${right}</div>
145
156
  </div>`;
146
157
  }
147
158
  function goalCardHtml(g) {
148
- const { r, achieved, ringHtml, goalHref, right, metaHtml } = goalRenderFacts(g, 36);
159
+ const { r, achieved, ringHtml, goalHref, right, metaHtml, catHtml } = goalRenderFacts(g, 36);
149
160
  return `<div class="goal-card" data-goal-id="${escapeHtml(String(g.id))}">
150
161
  <div class="goal-card__head">
151
162
  <div class="gr-ringcol">${ringHtml}</div>
152
163
  <a class="goal-card__title gr-title" href="${goalHref}"${subtitleAttr(g)}>${escapeHtml(g.title || 'Untitled goal')}</a>
153
164
  </div>
154
- <div class="goal-card__meta">${metaHtml}</div>
165
+ <div class="goal-card__meta">${metaHtml}${catHtml}</div>
155
166
  ${achieved ? '' : goalRollLine(r)}
156
167
  <div class="goal-card__foot">${right}</div>
157
168
  </div>`;
@@ -709,7 +720,10 @@
709
720
  // still never re-derives the protected roster.
710
721
  const gateSpan = g.scope_protected
711
722
  ? '<span class="goal-badge goal-badge--locked" title="Protected module scope — join by request; an Archon admits members">🛡 Archon-mediated</span>' : '';
712
- meta.innerHTML = `<span>${escapeHtml(g.version_id || '')}</span> ${statusBadge(g.status)} ${lockSpan} ${gateSpan} ${scope} ${depsSpan}`;
723
+ // task 1003276 — the human category, beside the machine-readable module
724
+ // scope so the difference is visible: `scope` is the wall that refuses work
725
+ // (ADR 0086); the chip is a word for a person and gates nothing (ADR 0192).
726
+ meta.innerHTML = `<span>${escapeHtml(g.version_id || '')}</span> ${statusBadge(g.status)} ${categoryChip(g.category)} ${lockSpan} ${gateSpan} ${scope} ${depsSpan}`;
713
727
  }
714
728
 
715
729
  // task 1762: the goal's own owner/manager (or an Archon) can invite/manage
@@ -2172,3 +2172,12 @@ html.palette-open { overflow: hidden; }
2172
2172
  .palette__panel { margin-top: 6vh; }
2173
2173
  .palette__group { min-width: 56px; }
2174
2174
  }
2175
+ /* task 1003276 — the human CATEGORY chip. Quiet by design: it sits beside the
2176
+ meta line, not above the title, because it is a label a person scans, never a
2177
+ status or a permission (ADR 0192). No hover/pointer affordance and no href —
2178
+ a per-category destination would be a navigation tier, which is exactly the
2179
+ second hierarchy this feature must not become. On a TASK the chip is the one
2180
+ inherited from its goal, and says so, so nobody hunts for a per-task editor. */
2181
+ .gr-cat { display: inline-flex; align-items: baseline; gap: 5px; margin-left: 8px; padding: 1px 8px; border: 1px solid var(--rule-soft); border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: .01em; color: var(--ink-soft); background: var(--surface-2); vertical-align: baseline; white-space: nowrap; }
2182
+ .gr-cat--inherited { font-weight: 550; color: var(--ink-faint); }
2183
+ .gr-cat__from { font-style: normal; font-weight: 500; font-size: 10px; color: var(--ink-faint); opacity: .85; }
@@ -193,7 +193,7 @@
193
193
  `Task description:\n${desc}`;
194
194
  }
195
195
 
196
- function renderTask(t, lastClaim, viewer) {
196
+ function renderTask(t, lastClaim, viewer, goal) {
197
197
  document.title = `#${t.id} — ${t.title || 'Task'} — ${document.title.split(' — ').pop()}`;
198
198
  const sub = $('#task-sub');
199
199
  if (sub) sub.textContent = `Task #${t.id}`;
@@ -222,7 +222,14 @@
222
222
  }
223
223
 
224
224
  if (t.goal_id) {
225
- parts.push(`<p class="td-goal">Goal: <a href="/goals#/goal/${encodeURIComponent(t.goal_id)}">Goal #${escapeHtml(String(t.goal_id))}</a></p>`);
225
+ // task 1003276 — the goal line names the goal and carries the category this
226
+ // task INHERITS from it. Two legibility fixes in one line: the link used to
227
+ // read "Goal #1000072", which tells a person nothing they didn't already
228
+ // know, and the chip answers "what kind of work is this" without opening
229
+ // the goal. Marked inherited because a task carries no category of its own.
230
+ const goalName = goal && goal.title ? ` — ${escapeHtml(goal.title)}` : '';
231
+ const catChip = window.OTB.categoryChip(goal && goal.category, { inherited: true });
232
+ parts.push(`<p class="td-goal">Goal: <a href="/goals#/goal/${encodeURIComponent(t.goal_id)}">Goal #${escapeHtml(String(t.goal_id))}</a>${goalName}${catChip}</p>`);
226
233
  }
227
234
 
228
235
  // Claim history (task 751 — best-effort: the API surfaces the most recent
@@ -356,7 +363,17 @@
356
363
  const me = await getJSON(`${API}/me`, { softAuth: true });
357
364
  viewer = (me && me.builder) ? me.builder : null;
358
365
  } catch (_) { /* cosmetic — the server enforces regardless */ }
359
- renderTask(d.task, d.last_claim || null, viewer);
366
+ // task 1003276 — the task's goal, for its name and the category the task
367
+ // inherits. Best-effort and non-blocking on failure: the page has always
368
+ // rendered without it, and a category is a nicety, never a prerequisite.
369
+ let goal = null;
370
+ if (d.task.goal_id != null) {
371
+ try {
372
+ const g = await getJSON(`${API}/goals/${encodeURIComponent(d.task.goal_id)}`, { softAuth: true });
373
+ goal = (g && g.goal) ? g.goal : null;
374
+ } catch (_) { /* cosmetic — the goal link still resolves without it */ }
375
+ }
376
+ renderTask(d.task, d.last_claim || null, viewer, goal);
360
377
  } catch (err) {
361
378
  if (err && err.code === AUTH_NEEDED) { toAuth(); return; }
362
379
  if (err && err.status === 404) { renderNotFound(id); return; }
@@ -337,6 +337,12 @@
337
337
  // task 1002955: how old is this task? A ready queue reads as equally fresh
338
338
  // without it; the exact date rides the tooltip.
339
339
  const ageChip = window.OTBKit.ageChipHtml(t.created_at);
340
+ // task 1003276 — the category this task inherits from its goal, marked as
341
+ // inherited so nobody looks for a per-task editor that does not exist. It
342
+ // is a label only: it never filters or orders this board, because nothing
343
+ // a person may not act on should decide what the queue offers (ADR 0192).
344
+ const catChip = window.OTB.categoryChip(
345
+ (taskState.goalCategories || {})[String(t.goal_id)], { inherited: true });
340
346
  const selected = selection.claim.has(String(t.id));
341
347
  return `
342
348
  <div class="task${selected ? ' task--selected' : ''}" data-task-id="${t.id}">
@@ -358,6 +364,7 @@
358
364
  <span>${cpmText}</span>
359
365
  ${touchesChip}
360
366
  ${ageChip}
367
+ ${catChip}
361
368
  </div>
362
369
  </div>
363
370
  <div class="task__credits">
@@ -1071,9 +1078,17 @@
1071
1078
 
1072
1079
  // task 1755: goal id → title map for the Goal filter (best-effort).
1073
1080
  taskState.goalTitles = {};
1081
+ // task 1003276 — a task INHERITS its goal's category (it stores none of its
1082
+ // own), so the board resolves the label the same way the schema does: through
1083
+ // goal_id. The goals fetch below is already made for the titles, so the chip
1084
+ // costs no extra request and no server change.
1085
+ taskState.goalCategories = {};
1074
1086
  try {
1075
1087
  const gr = await getJSON(`${API}/goals`);
1076
- (gr.goals || []).forEach((g) => { taskState.goalTitles[g.id] = g.title; });
1088
+ (gr.goals || []).forEach((g) => {
1089
+ taskState.goalTitles[g.id] = g.title;
1090
+ taskState.goalCategories[g.id] = g.category || null;
1091
+ });
1077
1092
  } catch (_) { /* filter labels fall back to 'Goal #id' */ }
1078
1093
 
1079
1094
  // Claimable + active claims in one call. A 403 means the primer gate — show
@@ -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.371",
3
+ "version": "1.19.373",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.371",
9
+ "version": "1.19.373",
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.371",
3
+ "version": "1.19.373",
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();
package/src/module-api.js CHANGED
@@ -1172,8 +1172,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1172
1172
  // landed since 1.19.369 with no explicit bump. run 32904231221. (task 1002620)
1173
1173
  // 1.19.371 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1174
1174
  // landed since 1.19.370 with no explicit bump. run 32904582252. (task 1002620)
1175
+ // 1.19.372 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1176
+ // landed since 1.19.371 with no explicit bump. run 32905166786. (task 1002620)
1177
+ // 1.19.373 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1178
+ // landed since 1.19.372 with no explicit bump. run 32906118616. (task 1002620)
1175
1179
  // ---------------------------------------------------------------------------
1176
- const CORE_VERSION = '1.19.371'; // CI auto-patch carrier (ADR 0161); see changelog above
1180
+ const CORE_VERSION = '1.19.373'; // CI auto-patch carrier (ADR 0161); see changelog above
1177
1181
 
1178
1182
  // A namespaced logger so a module's log lines are attributable + consistent.
1179
1183
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -16,7 +16,10 @@ import { createRequire } from 'node:module';
16
16
 
17
17
  const require = createRequire(import.meta.url);
18
18
  const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
19
- const { bumpPatch, CONST_RE } = require(path.join(ROOT, 'scripts', 'gds', 'bump-version.js'));
19
+ const bump = require(path.join(ROOT, 'scripts', 'gds', 'bump-version.js'));
20
+ const { bumpPatch, CONST_RE } = bump;
21
+ // The LIVE test below reads this repo's real release history (task 1003202).
22
+ const { execFileSync } = require('node:child_process');
20
23
 
21
24
  const SAMPLE = [
22
25
  '// prologue',
@@ -111,3 +114,76 @@ test('publish.yml wires the terminator: guard step present, every armed step gat
111
114
  const gates = (PUBLISH_YML.match(/steps\.relhead\.outputs\.release_head == 'false'/g) || []).length;
112
115
  assert.ok(gates >= 7, `every post-guard armed step must carry the gate (setup-node, install, resolve, bump, pack, publish, tag) — found ${gates}`);
113
116
  });
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // laneReleasedVersions — the token-free "is this version already published?"
120
+ // (task 1003202 / blocker 1000115, the OIDC trusted-publishing migration).
121
+ //
122
+ // publish.yml used to ask the REGISTRY, which needs a credential: @bongos/core is
123
+ // private, so an unauthenticated `npm view` is a flat E404 rather than a "no".
124
+ // OIDC authenticates `npm publish` and nothing else, so keeping that check would
125
+ // have reported "not taken" every run, skipped the auto-bump, and collided on the
126
+ // published version — working for exactly one release before breaking. git holds
127
+ // the same answer, needs no auth, and is the source the cascade terminator above
128
+ // already trusts.
129
+ // ---------------------------------------------------------------------------
130
+
131
+ test('laneReleasedVersions reads the versions this lane announced', () => {
132
+ const subjects = [
133
+ 'release: core 1.19.368 — CI auto-patch carrier (publish-on-merge, ADR 0161)',
134
+ 'Merge pull request #385 from lars589/some-branch',
135
+ 'release: core 1.19.367 — CI auto-patch carrier (publish-on-merge, ADR 0161)',
136
+ 'feat(security): something unrelated',
137
+ ];
138
+ const v = bump.laneReleasedVersions(subjects);
139
+ assert.deepEqual([...v].sort(), ['1.19.367', '1.19.368']);
140
+ assert.equal(v.has('1.19.369'), false, 'a version never announced must read as free');
141
+ });
142
+
143
+ test('a HUMAN release commit is NOT counted — that version still needs publishing', () => {
144
+ // The same distinction RELEASE_COMMIT_RE was written to make for the cascade
145
+ // terminator. Counting it here would skip the publish of a hand-cut release.
146
+ const v = bump.laneReleasedVersions([
147
+ 'release: core 1.19.2 — carrier for the init-blocker fixes',
148
+ 'release: core 1.20.0 — hand-cut minor',
149
+ ]);
150
+ assert.equal(v.size, 0);
151
+ });
152
+
153
+ test('malformed and empty input yields an empty set, never a throw', () => {
154
+ // This runs inside the publish lane. A crash here would take down releases;
155
+ // an empty set degrades to "nothing is taken", which the publish itself
156
+ // catches with EPUBLISHCONFLICT — loud, and nothing wrong ships.
157
+ assert.equal(bump.laneReleasedVersions([]).size, 0);
158
+ assert.equal(bump.laneReleasedVersions().size, 0);
159
+ assert.equal(bump.laneReleasedVersions([null, undefined, '', 42]).size, 0);
160
+ });
161
+
162
+ test('laneReleasedVersions and isReleaseCommit agree on what a lane release looks like', () => {
163
+ // They share RELEASE_COMMIT_RE on purpose: if one ever recognises a subject the
164
+ // other does not, the cascade terminator and the version check disagree about
165
+ // the same commit, and the lane either double-publishes or silently no-ops.
166
+ const subjects = [
167
+ 'release: core 1.19.368 — CI auto-patch carrier (publish-on-merge, ADR 0161)',
168
+ 'release: core 1.19.2 — carrier for the init-blocker fixes',
169
+ 'chore: not a release at all',
170
+ ];
171
+ for (const s of subjects) {
172
+ const counted = bump.laneReleasedVersions([s]).size === 1;
173
+ assert.equal(counted, bump.isReleaseCommit(s), `disagreement on: ${s}`);
174
+ }
175
+ });
176
+
177
+ test('LIVE: the version HEAD declares is one this lane already released', () => {
178
+ // The steady state the lane depends on: HEAD carries the version set by the
179
+ // previous release commit, so `taken` is true and the auto-bump fires. If this
180
+ // ever reads false on a normal main checkout, the lane would try to republish
181
+ // an existing version.
182
+ const headVersion = /^const CORE_VERSION = '([0-9.]+)';/m
183
+ .exec(fs.readFileSync(path.join(ROOT, 'src', 'module-api.js'), 'utf8'))[1];
184
+ const subjects = execFileSync('git', ['log', '--format=%s', 'HEAD'], { encoding: 'utf8', cwd: ROOT }).split('\n');
185
+ const released = bump.laneReleasedVersions(subjects);
186
+ assert.ok(released.size > 100, `expected a long release history, saw ${released.size}`);
187
+ assert.equal(released.has(headVersion), true,
188
+ `HEAD declares ${headVersion}, which this lane has no release commit for — the auto-bump would not fire`);
189
+ });
@@ -145,6 +145,34 @@ test('an unknown category is a 404, not a silently-created one', async () => {
145
145
  assert.equal(res.statusCode, 404);
146
146
  });
147
147
 
148
+ // ---- the advisory (task 1003277) --------------------------------------------
149
+
150
+ test('an uncategorised goal earns an advisory that carries the live set and the create path', () => {
151
+ const adv = gc.adviseCategory({ categoryId: null, categories: [
152
+ { id: 3, slug: 'security', label: 'Security' },
153
+ { id: 6, slug: 'economics', label: 'Economics' },
154
+ ] });
155
+ assert.equal(adv.reason, 'uncategorised');
156
+ assert.deepEqual(adv.categories.map((c) => c.slug), ['security', 'economics'],
157
+ 'the live set rides along so a client offers real choices, never hard-coded labels');
158
+ assert.match(adv.message, /POST \/work-categories/, 'the prompt has somewhere to write into');
159
+ assert.match(adv.message, /designed prompt, not an error/, 'the criterion\'s own framing travels with the advice');
160
+ assert.match(adv.message, /gates nothing/, 'and so does the rule that outranks the feature');
161
+ });
162
+
163
+ test('a categorised goal gets NO advisory — silence, the fail-open posture', () => {
164
+ assert.equal(gc.adviseCategory({ categoryId: 3, categories: [] }), null);
165
+ });
166
+
167
+ test('the advisory is advice on a 201, never a refusal', () => {
168
+ // Pinned in source: POST /goals attaches category_advisory to its SUCCESS body
169
+ // and never fails the create over it (best-effort, wrapped in try).
170
+ const routes = read('modules', 'lifecycle', 'routes', 'goals.js');
171
+ assert.match(routes, /category_advisory = goalCategory\.adviseCategory/);
172
+ assert.match(routes, /res\.status\(201\)\.json\(\{ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory \}\)/,
173
+ 'it rides the 201 — advice appended to a request that already succeeded');
174
+ });
175
+
148
176
  // ---- decoration ------------------------------------------------------------
149
177
 
150
178
  test('a goal carries a readable category, and an uncategorised one carries null', () => {