@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.
- package/.bongos-core.json +34 -29
- package/.claude/skills/goal-create/SKILL.md +7 -4
- package/clients/bongos-client/index.d.ts +1 -1
- 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 +4 -2
- package/docs/copy-inventory.md +134 -132
- package/docs/copy-registry.json +180 -162
- package/modules/hall-ui/public/dom-utils.js +31 -0
- package/modules/hall-ui/public/goals-page.js +34 -0
- package/modules/hall-ui/public/goals-render.js +21 -7
- package/modules/hall-ui/public/style.css +9 -0
- package/modules/hall-ui/public/task.js +20 -3
- package/modules/hall-ui/public/work.js +16 -1
- 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/src/module-api.js +5 -1
- package/tests/bump_version.mjs +77 -1
- package/tests/goal_category.mjs +28 -0
- package/tests/goal_category_ui.mjs +279 -0
- package/tests/goal_criterion_confirm_ui.mjs +6 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|
|
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
|
-
|
|
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.373",
|
|
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.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.
|
|
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
|
-
|
|
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.
|
|
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');
|
package/tests/bump_version.mjs
CHANGED
|
@@ -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
|
|
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
|
+
});
|
package/tests/goal_category.mjs
CHANGED
|
@@ -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', () => {
|