@bongos/core 1.17.2 → 1.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.bongos-core.json +292 -32
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -2
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +65 -26
  8. package/docs/api-reference.md +3 -2
  9. package/modules/hall-ui/public/builders.js +96 -646
  10. package/modules/hall-ui/public/goal-inbox.js +67 -26
  11. package/modules/hall-ui/public/goals-page.js +4 -1
  12. package/modules/hall-ui/public/goals.html +8 -0
  13. package/modules/hall-ui/public/hall-kit.js +474 -0
  14. package/modules/hall-ui/public/hall-widgets.js +11 -15
  15. package/modules/hall-ui/public/instances-panel.js +24 -3
  16. package/modules/hall-ui/public/new-project-wizard.js +6 -5
  17. package/modules/hall-ui/public/style.css +125 -0
  18. package/modules/hall-ui/public/work.css +194 -0
  19. package/modules/hall-ui/public/work.html +46 -374
  20. package/modules/hall-ui/public/work.js +137 -102
  21. package/modules/lifecycle/criterion-suggest.js +80 -0
  22. package/modules/lifecycle/db.js +42 -5
  23. package/modules/lifecycle/routes/goals.js +26 -0
  24. package/modules/lifecycle/routes/tasks.js +21 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/scripts/hall-preview/README.md +103 -0
  28. package/scripts/hall-preview/fixtures/access-requests.json +1 -0
  29. package/scripts/hall-preview/fixtures/achievements.json +76 -0
  30. package/scripts/hall-preview/fixtures/analytics__builder.json +251 -0
  31. package/scripts/hall-preview/fixtures/audit-log.json +1 -0
  32. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +1 -0
  33. package/scripts/hall-preview/fixtures/builders__active-claims.json +1 -0
  34. package/scripts/hall-preview/fixtures/builders__directory.json +81 -0
  35. package/scripts/hall-preview/fixtures/builders__roster.json +1 -0
  36. package/scripts/hall-preview/fixtures/goals.json +502 -0
  37. package/scripts/hall-preview/fixtures/goals__25.json +96 -0
  38. package/scripts/hall-preview/fixtures/goals__26.json +67 -0
  39. package/scripts/hall-preview/fixtures/goals__3.json +103 -0
  40. package/scripts/hall-preview/fixtures/goals__35.json +49 -0
  41. package/scripts/hall-preview/fixtures/goals__conflicts.json +154 -0
  42. package/scripts/hall-preview/fixtures/goals__detail.json +96 -0
  43. package/scripts/hall-preview/fixtures/goals__inbox.json +5 -0
  44. package/scripts/hall-preview/fixtures/goals__requests.json +3 -0
  45. package/scripts/hall-preview/fixtures/grades__by-builder.json +1 -0
  46. package/scripts/hall-preview/fixtures/inbox.json +330 -0
  47. package/scripts/hall-preview/fixtures/instance.json +124 -0
  48. package/scripts/hall-preview/fixtures/leaderboard.json +184 -0
  49. package/scripts/hall-preview/fixtures/me.json +476 -0
  50. package/scripts/hall-preview/fixtures/me__recent-ships.json +304 -0
  51. package/scripts/hall-preview/fixtures/override-requests.json +1 -0
  52. package/scripts/hall-preview/fixtures/provisioning__fleet.json +1 -0
  53. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +1 -0
  54. package/scripts/hall-preview/fixtures/provisioning__instances.json +1 -0
  55. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +516 -0
  56. package/scripts/hall-preview/fixtures/public__bounty-table.json +37 -0
  57. package/scripts/hall-preview/fixtures/public__cost-summary.json +742 -0
  58. package/scripts/hall-preview/fixtures/public__estimation-drift.json +41 -0
  59. package/scripts/hall-preview/fixtures/public__grades.json +50 -0
  60. package/scripts/hall-preview/fixtures/public__progress.json +124 -0
  61. package/scripts/hall-preview/fixtures/public__recent-shipped.json +426 -0
  62. package/scripts/hall-preview/fixtures/public__repo-health.json +160 -0
  63. package/scripts/hall-preview/fixtures/public__repo-info.json +5 -0
  64. package/scripts/hall-preview/fixtures/security__docs.json +1 -0
  65. package/scripts/hall-preview/fixtures/security__reports.json +1 -0
  66. package/scripts/hall-preview/fixtures/sessions__search.json +1 -0
  67. package/scripts/hall-preview/fixtures/tasks.json +2420 -0
  68. package/scripts/hall-preview/fixtures/tasks__claimable.json +2555 -0
  69. package/scripts/hall-preview/fixtures/tasks__detail.json +111 -0
  70. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +330 -0
  71. package/scripts/hall-preview/fixtures/versions.json +89 -0
  72. package/scripts/hall-preview/refresh-fixtures.js +77 -0
  73. package/scripts/hall-preview/server.js +178 -0
  74. package/scripts/migrate.sh +29 -4
  75. package/src/bongos/optimizer.js +37 -2
  76. package/tests/criterion_suggest.mjs +106 -0
  77. package/tests/migrate_root_resolution.mjs +97 -0
@@ -36,6 +36,8 @@
36
36
  // file's exact variants (date-only fmtDate; a 2200ms clearing toast).
37
37
  // fmtAgo / toAuth / getJSON / postJSON stay local below.
38
38
  const { escapeHtml, $, fmtNum } = window.OTB;
39
+ // Shared component kit (hall-kit.js, task 2166): tabs, pager, filter bar.
40
+ const { makePager, paginate, wirePager, pagerBarHtml, makeTabs, makeFilterBar } = window.OTBKit;
39
41
  const fmtDate = (iso) => window.OTB.fmtDate(iso);
40
42
  const toast = (msg) => window.OTB.toast(msg, { duration: 2200, clear: true });
41
43
  const curLabel = (caps) => { const l = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits'; return caps ? l.charAt(0).toUpperCase() + l.slice(1) : l; };
@@ -102,25 +104,11 @@
102
104
  return data;
103
105
  }
104
106
 
105
- // ---- paginated lists: per-page dropdown + Prev/Next (task 1931/1955) -------
106
- // All four Work-page lists (In Progress, Ready, Completed, Backlog) paginate
107
- // the same way, through one shared helper. Each list owns a `pager` (page +
108
- // per-page, persisted to its own localStorage key) and a DOM id `prefix`;
109
- // renderPagerBar paints `#<prefix>-{pager,range,page,prev,next,per-page}`.
110
- // Page/per-page live in module scope so a background poll / action refresh
111
- // re-renders on the SAME page — only an explicit filter/per-page change resets.
112
- const PER_PAGE_OPTIONS = [10, 25, 50, 100];
113
- const MIN_PER_PAGE = PER_PAGE_OPTIONS[0]; // ≤ this fits one page — hide the bar
114
- const DEFAULT_PER_PAGE = 25;
115
- function loadPerPage(key, fallback) {
116
- try {
117
- const v = Number(window.localStorage.getItem(key));
118
- return PER_PAGE_OPTIONS.includes(v) ? v : fallback;
119
- } catch (_) { return fallback; }
120
- }
121
- function makePager(prefix, key) {
122
- return { prefix, key, page: 1, perPage: loadPerPage(key, DEFAULT_PER_PAGE) };
123
- }
107
+ // ---- paginated lists (task 1931/1955 → kit, task 2170) --------------------
108
+ // All four Work-page lists paginate through the shared OTBKit pager (the
109
+ // canonical copy of the helper that used to live here). localStorage keys are
110
+ // unchanged so builders' per-page prefs survive. The bar markup is stamped
111
+ // into the #<prefix>-pager-mount divs at boot (wireEvents) via pagerBarHtml.
124
112
  const pagers = {
125
113
  wip: makePager('wip', 'otb.work.wipPerPage'),
126
114
  ready: makePager('ready', 'otb.work.readyPerPage'),
@@ -128,6 +116,11 @@
128
116
  backlog: makePager('backlog', 'otb.work.backlogPerPage'),
129
117
  };
130
118
 
119
+ // task 2170: the four sections are hash-synced tabs (Ready default). Built in
120
+ // wireEvents; renders feed the tab count pills via setTabCount.
121
+ let workTabs = null;
122
+ function setTabCount(id, n) { if (workTabs) workTabs.setCount(id, n); }
123
+
131
124
  // A section-header count pill: reveal it and set its number once data lands.
132
125
  function setCountPill(sel, n) {
133
126
  const el = $(sel);
@@ -136,53 +129,6 @@
136
129
  el.textContent = String(n);
137
130
  }
138
131
 
139
- // Slice `list` to the pager's current page (clamped — the filtered list may
140
- // have shrunk under a poll), paint the bar, and return the slice to render.
141
- // The bar hides when the whole list fits one minimum page.
142
- function paginate(list, p) {
143
- const total = list.length;
144
- const totalPages = Math.max(1, Math.ceil(total / p.perPage));
145
- if (p.page > totalPages) p.page = totalPages;
146
- if (p.page < 1) p.page = 1;
147
- const start = (p.page - 1) * p.perPage;
148
- const slice = list.slice(start, start + p.perPage);
149
- renderPagerBar(p, total, totalPages, start, slice.length);
150
- return slice;
151
- }
152
-
153
- function renderPagerBar(p, total, totalPages, start, shownCount) {
154
- const pager = $(`#${p.prefix}-pager`);
155
- if (!pager) return;
156
- if (total <= MIN_PER_PAGE) { pager.hidden = true; return; }
157
- pager.hidden = false;
158
- const range = $(`#${p.prefix}-range`);
159
- if (range) range.textContent = total
160
- ? `Showing ${start + 1}–${start + shownCount} of ${total}`
161
- : 'Showing 0 of 0';
162
- const ind = $(`#${p.prefix}-page`);
163
- if (ind) ind.textContent = `Page ${p.page} of ${totalPages}`;
164
- const prev = $(`#${p.prefix}-prev`);
165
- if (prev) prev.disabled = p.page <= 1;
166
- const next = $(`#${p.prefix}-next`);
167
- if (next) next.disabled = p.page >= totalPages;
168
- const sel = $(`#${p.prefix}-per-page`);
169
- if (sel && sel.value !== String(p.perPage)) sel.value = String(p.perPage);
170
- }
171
-
172
- // Wire a pager's controls once (per-page dropdown + Prev/Next). `rerender`
173
- // re-renders that list; a per-page change resets to page 1 and persists.
174
- function wirePager(p, rerender) {
175
- $(`#${p.prefix}-per-page`)?.addEventListener('change', (e) => {
176
- const v = Number(e.target.value);
177
- if (!PER_PAGE_OPTIONS.includes(v)) return;
178
- p.perPage = v; p.page = 1;
179
- try { window.localStorage.setItem(p.key, String(v)); } catch (_) { /* private mode → in-memory only */ }
180
- rerender();
181
- });
182
- $(`#${p.prefix}-prev`)?.addEventListener('click', () => { if (p.page > 1) { p.page -= 1; rerender(); } });
183
- $(`#${p.prefix}-next`)?.addEventListener('click', () => { p.page += 1; rerender(); });
184
- }
185
-
186
132
  // ---- 1a. live in-flight list (active claims) -----------------------------
187
133
 
188
134
  function renderInFlight(active) {
@@ -196,6 +142,7 @@
196
142
  const isMine = (c) => viewer.id != null && Number(c.builder_id) === Number(viewer.id);
197
143
  const rows = all.slice().sort((a, b) => (isMine(b) ? 1 : 0) - (isMine(a) ? 1 : 0));
198
144
  setCountPill('#wip-count', all.length);
145
+ setTabCount('in-progress', all.length);
199
146
  if (!rows.length) {
200
147
  tbody.innerHTML = `<tr><td colspan="6" class="ledger__empty">No tasks are in progress right now.</td></tr>`;
201
148
  pagers.wip.page = 1;
@@ -378,8 +325,10 @@
378
325
 
379
326
  const count = $('#claim-count');
380
327
  if (count) count.textContent = `${list.length} ${list.length === 1 ? 'task' : 'tasks'} waiting`;
381
- // task 1899: header count pill reflects the filtered-list length.
328
+ // task 1899: header count pill reflects the filtered-list length; the tab
329
+ // pill (task 2170) carries the FULL ready count, filters notwithstanding.
382
330
  setCountPill('#ready-count', list.length);
331
+ setTabCount('ready', taskState.all.length);
383
332
 
384
333
  if (!list.length) {
385
334
  root.innerHTML = `<div class="profile__placeholder">No tasks match these filters. Try widening them.</div>`;
@@ -405,9 +354,11 @@
405
354
  ? '<span class="task__tag task__tag--newcomer">newcomer</span>'
406
355
  : '';
407
356
  const rankTag = rankTagHtml(t.requires_rank);
357
+ // task 2170 density pass: the multi-line path dump becomes a compact
358
+ // "✎ N files" chip in the meta row; the full list rides the tooltip.
408
359
  const touches = Array.isArray(t.touches) ? t.touches : [];
409
- const touchesLine = touches.length
410
- ? `<div class="task__touches" title="Files this task is responsible for">✎ ${touches.map(escapeHtml).join(', ')}</div>`
360
+ const touchesChip = touches.length
361
+ ? `<span class="task__touches" title="${escapeHtml(touches.join('\n'))}">✎ ${touches.length} file${touches.length === 1 ? '' : 's'}</span>`
411
362
  : '';
412
363
  const selected = selection.claim.has(String(t.id));
413
364
  return `
@@ -428,8 +379,8 @@
428
379
  ${t.priority ? `<span class="task__tag">${escapeHtml(t.priority)}</span>` : ''}
429
380
  <span>${minutesText}</span>
430
381
  <span>${cpmText}</span>
382
+ ${touchesChip}
431
383
  </div>
432
- ${touchesLine}
433
384
  </div>
434
385
  <div class="task__credits">
435
386
  <span class="task__credits-num">${fmtNum(window.OTBBranding?.taskCreditValue?.(t) ?? t.credits_reward)}</span>
@@ -437,13 +388,26 @@
437
388
  </div>
438
389
  <div class="task__actions">
439
390
  <button class="task__claim" type="button" data-task-id="${t.id}" aria-label="Claim task ${t.id}">Claim</button>
440
- <button class="task__copy" type="button" data-task-id="${t.id}" title="Copy the task prompt to your clipboard" aria-label="Copy prompt for task ${t.id}"><span class="task__copy-icon" aria-hidden="true">📋</span><span class="task__copy-label">Copy prompt</span></button>
391
+ <button class="task__copy" type="button" data-task-id="${t.id}" title="Copy the task prompt to your clipboard" aria-label="Copy prompt for task ${t.id}"><span class="task__copy-icon" aria-hidden="true">📋</span></button>
441
392
  </div>
442
393
  </div>
443
394
  `;
444
395
  }).join('');
445
396
  }
446
397
 
398
+ // task 2170: the filter bar restores URL-query state at build time, but the
399
+ // Version/Goal selects fill their options LATER (from /versions and the
400
+ // claimable feed) — so a shared URL's value has nothing to select against
401
+ // until now. Returns true when it changed the control.
402
+ function restoreUrlFilter(id) {
403
+ const want = new URLSearchParams(window.location.search).get(id);
404
+ const sel = $(`#${id}`);
405
+ if (!want || !sel || sel.value === want) return false;
406
+ if (![...sel.options].some((o) => o.value === want)) return false;
407
+ sel.value = want;
408
+ return true;
409
+ }
410
+
447
411
  function populateVersionFilter(versions) {
448
412
  const sel = $('#f-version');
449
413
  if (!sel) return;
@@ -990,11 +954,11 @@
990
954
  if (Array.isArray(tasks)) historyRows = tasks;
991
955
  const root = $('#history-list');
992
956
  if (!root) return;
993
- // task 1754: reveal the Completed group only when there are shipped tasks.
994
- const scroll = $('#history-scroll');
995
- if (scroll) scroll.hidden = !historyRows.length;
957
+ // task 2170: section visibility is tab-owned now — the render only fills
958
+ // the panel and feeds the counts.
996
959
  const rows = historyRows;
997
960
  setCountPill('#done-count', rows.length);
961
+ setTabCount('completed', rows.length);
998
962
  if (!rows.length) {
999
963
  root.innerHTML = `<div class="profile__placeholder">Nothing shipped yet.</div>`;
1000
964
  pagers.done.page = 1;
@@ -1029,16 +993,17 @@
1029
993
 
1030
994
  function renderBacklog(tasks) {
1031
995
  if (Array.isArray(tasks)) backlogRows = tasks;
1032
- const scroll = $('#backlog-scroll');
1033
996
  const root = $('#backlog-list');
1034
997
  if (!root) return;
1035
- // Hide the whole section only when there's genuinely no backlog; when a
1036
- // search is active but filters everything out, keep the section visible so
1037
- // the "0 tasks" count explains the empty list (task 1829).
1038
- if (!backlogRows.length) { if (scroll) scroll.hidden = true; return; }
998
+ // task 2170: section visibility is tab-owned now. The tab pill carries the
999
+ // full backlog count; the heading count reflects the search-filtered list.
1000
+ setTabCount('backlog', backlogRows.length);
1001
+ if (!backlogRows.length) {
1002
+ root.innerHTML = '<div class="profile__placeholder">Nothing in the backlog right now.</div>';
1003
+ return;
1004
+ }
1039
1005
  const q = searchQuery();
1040
1006
  const list = q ? backlogRows.filter((t) => matchesSearch(t, q)) : backlogRows;
1041
- if (scroll) scroll.hidden = false;
1042
1007
  const countEl = $('#backlog-count');
1043
1008
  if (countEl) countEl.textContent = `${list.length} ${list.length === 1 ? 'task' : 'tasks'}`;
1044
1009
  const shown = paginate(list, pagers.backlog);
@@ -1094,10 +1059,17 @@
1094
1059
  // ---- gate notice (signed in, primer not yet read) ------------------------
1095
1060
 
1096
1061
  function showClaimGate() {
1062
+ // task 2170: the primer gate replaces the whole tabbed board (the claimable
1063
+ // feed and active-claims read are both primer-gated server-side, so every
1064
+ // tab would be empty-but-misleading). Gate notice on, tabs + panels off.
1097
1065
  const gate = $('#gate-scroll');
1098
- const claim = $('#claim-scroll');
1099
1066
  if (gate) gate.hidden = false;
1100
- if (claim) claim.hidden = true;
1067
+ const tabsMount = $('#work-tabs');
1068
+ if (tabsMount) tabsMount.hidden = true;
1069
+ for (const id of ['claim-scroll', 'wip-scroll', 'backlog-scroll', 'history-scroll']) {
1070
+ const el = document.getElementById(id);
1071
+ if (el) el.hidden = true;
1072
+ }
1101
1073
  }
1102
1074
 
1103
1075
  // ---- bootstrap -----------------------------------------------------------
@@ -1163,10 +1135,14 @@
1163
1135
  ]);
1164
1136
  taskState.versions = versionsResp.versions || [];
1165
1137
  populateVersionFilter(taskState.versions);
1138
+ restoreUrlFilter('f-version');
1166
1139
  renderInFlight(claimResp.active_claims || []);
1167
1140
  taskState.all = claimResp.claimable || [];
1168
1141
  renderClaimable();
1169
1142
  populateGoalFilter(); // task 1755
1143
+ // task 2170: a shared URL's goal filter can only apply once the options
1144
+ // exist — restore it now and re-render if it changed anything.
1145
+ if (restoreUrlFilter('f-goal')) renderClaimable();
1170
1146
  pruneSelection();
1171
1147
  renderBulkToolbar();
1172
1148
  } catch (err) {
@@ -1182,31 +1158,90 @@
1182
1158
  }
1183
1159
 
1184
1160
  function wireEvents() {
1185
- // task 1931: any filter/sort change re-paginates the Ready list from page 1.
1186
- const onReadyFilterChange = () => { pagers.ready.page = 1; renderClaimable(); };
1187
- $('#f-version')?.addEventListener('change', onReadyFilterChange);
1188
- $('#f-goal')?.addEventListener('change', onReadyFilterChange);
1189
- $('#f-kind')?.addEventListener('change', onReadyFilterChange);
1190
- $('#f-discipline')?.addEventListener('change', onReadyFilterChange);
1191
- $('#f-newcomer')?.addEventListener('change', onReadyFilterChange);
1192
- $('#f-fit')?.addEventListener('change', onReadyFilterChange);
1193
- $('#f-sort')?.addEventListener('change', onReadyFilterChange);
1194
- // task 1829: free-text search — 'input' fires on every keystroke and on the
1195
- // type=search clear (✕). Re-render BOTH the claimable and backlog lists,
1196
- // resetting each to page 1 so matches aren't hidden behind pagination.
1197
- $('#f-search')?.addEventListener('input', () => {
1198
- pagers.ready.page = 1;
1199
- pagers.backlog.page = 1;
1200
- renderClaimable();
1201
- renderBacklog();
1202
- });
1203
-
1204
- // task 1931/1955: pagination controls for all four Work-page lists.
1161
+ // task 2170: stamp the standard pager bars into their mounts, then wire —
1162
+ // the markup that used to be hand-copied four times in work.html.
1163
+ for (const [prefix, label] of [['ready', 'Ready tasks'], ['wip', 'In-progress tasks'], ['done', 'Completed tasks'], ['backlog', 'Backlog tasks']]) {
1164
+ const m = $(`#${prefix}-pager-mount`);
1165
+ if (m) m.innerHTML = pagerBarHtml(prefix, { label });
1166
+ }
1205
1167
  wirePager(pagers.ready, renderClaimable);
1206
1168
  wirePager(pagers.wip, renderInFlight);
1207
1169
  wirePager(pagers.done, renderHistory);
1208
1170
  wirePager(pagers.backlog, renderBacklog);
1209
1171
 
1172
+ // task 2170: the four sections become hash-synced tabs, Ready first. The
1173
+ // kit never claims "#/…" hashes, so the task/idea/blocker overlay deep
1174
+ // links keep working. Selection is scoped per tab — switching clears it.
1175
+ workTabs = makeTabs({
1176
+ mount: $('#work-tabs'),
1177
+ hash: true,
1178
+ initial: 'ready',
1179
+ tabs: [
1180
+ { id: 'ready', label: 'Ready', panel: 'claim-scroll' },
1181
+ { id: 'in-progress', label: 'In Progress', panel: 'wip-scroll' },
1182
+ { id: 'backlog', label: 'Backlog', panel: 'backlog-scroll' },
1183
+ { id: 'completed', label: 'Completed', panel: 'history-scroll' },
1184
+ ],
1185
+ onChange: () => { if (selectionTotal()) clearSelection(); },
1186
+ });
1187
+
1188
+ // task 2170: filter diet — Search/Sort/Version primary, the rest behind
1189
+ // the kit's "Filters" disclosure; state rides the URL query (shareable).
1190
+ // Control ids are unchanged so renderClaimable's reads + the dynamic
1191
+ // version/goal option fills keep working untouched.
1192
+ makeFilterBar({
1193
+ mount: $('#work-filters'),
1194
+ search: { id: 'f-search', label: 'Search', placeholder: 'task #, title, or goal…' },
1195
+ primary: [
1196
+ {
1197
+ id: 'f-sort',
1198
+ label: 'Sort',
1199
+ options: [
1200
+ { value: 'cpm', label: `${curLabel()} / minute (high → low)` },
1201
+ { value: 'credits', label: `${curLabel()} (high → low)` },
1202
+ { value: 'minutes', label: 'minutes (low → high)' },
1203
+ { value: 'priority', label: 'priority (P0 first)' },
1204
+ { value: 'id', label: 'task number (low → high)' },
1205
+ ],
1206
+ },
1207
+ { id: 'f-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
1208
+ ],
1209
+ overflow: [
1210
+ { id: 'f-goal', label: 'Goal', options: [{ value: '', label: '— all goals —' }] },
1211
+ {
1212
+ id: 'f-kind',
1213
+ label: 'Kind',
1214
+ options: [{ value: '', label: '— any —' },
1215
+ 'feature', 'bug', 'infra', 'refactor', 'spike', 'learning-capture',
1216
+ 'cleanup', 'decision', 'blocker-resolution', 'unclassified'],
1217
+ },
1218
+ {
1219
+ id: 'f-discipline',
1220
+ label: 'Discipline',
1221
+ options: [{ value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui', 'unclassified'],
1222
+ },
1223
+ {
1224
+ id: 'f-newcomer',
1225
+ label: 'Newcomer',
1226
+ options: [{ value: '', label: '— any —' }, { value: 'yes', label: 'newcomer-friendly only' }],
1227
+ },
1228
+ {
1229
+ id: 'f-fit',
1230
+ label: 'Run mode',
1231
+ options: [{ value: '', label: '— any —' },
1232
+ { value: 'parallel', label: 'parallel-safe only' },
1233
+ { value: 'conflict', label: 'has file conflicts' }],
1234
+ },
1235
+ ],
1236
+ url: true,
1237
+ onChange: () => {
1238
+ pagers.ready.page = 1;
1239
+ pagers.backlog.page = 1;
1240
+ renderClaimable();
1241
+ renderBacklog();
1242
+ },
1243
+ });
1244
+
1210
1245
  $('#task-list')?.addEventListener('click', (e) => {
1211
1246
  // task 1759: the multi-select checkbox. Toggle the Set + the card's
1212
1247
  // highlight class directly (cheaper than a full renderClaimable) and
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+ // criterion-suggest.js — task 2165. A pure, deterministic RECOMMENDER: given a
3
+ // goal's criteria and a candidate task's title + description, rank the criteria
4
+ // by lexical overlap so task-create can SUGGEST the most likely done-when
5
+ // criterion to link. The builder always confirms or overrides — nothing is
6
+ // auto-linked (the "suggest, human picks" contract). No DB, no network, so it's
7
+ // unit-testable in the DB-free suite (tests live in modules/lifecycle/tests/).
8
+ //
9
+ // WHY lexical (not embeddings): the criterion set per goal is tiny (a handful),
10
+ // the signal is strong (criteria prose shares vocabulary with the tasks that
11
+ // fulfil it), and a deterministic ranker needs no model call, no key, and no
12
+ // flakiness — it slots straight into the create path. The scoring is a weighted
13
+ // term overlap: a term from the task TITLE counts double a term from the body,
14
+ // because the title is the denser signal of what the task is about.
15
+
16
+ // Common words that carry no discriminating signal — dropping them keeps a
17
+ // criterion from scoring on filler like "the work is done when…".
18
+ const STOPWORDS = new Set([
19
+ 'the', 'a', 'an', 'and', 'or', 'but', 'to', 'of', 'in', 'on', 'for', 'with',
20
+ 'without', 'is', 'are', 'be', 'was', 'were', 'it', 'its', 'this', 'that',
21
+ 'these', 'those', 'as', 'at', 'by', 'from', 'into', 'out', 'over', 'so',
22
+ 'then', 'than', 'not', 'no', 'can', 'do', 'does', 'done', 'when', 'which',
23
+ 'who', 'what', 'how', 'their', 'them', 'they', 'you', 'your', 'our', 'via',
24
+ 'per', 'each', 'one', 'all', 'any', 'new', 'use', 'used', 'using', 'make',
25
+ 'made', 'get', 'got', 'set', 'add', 'added', 'work', 'works', 'working',
26
+ ]);
27
+
28
+ // Lowercase, strip punctuation, drop stopwords + tokens shorter than 3 chars,
29
+ // and crudely singularize (trailing 's' on longer words) so "criteria"/"tasks"
30
+ // match "criterion"/"task"-ish stems. Deterministic and allocation-cheap.
31
+ function tokenize(text) {
32
+ return String(text || '')
33
+ .toLowerCase()
34
+ .replace(/[^a-z0-9\s]+/g, ' ')
35
+ .split(/\s+/)
36
+ .filter((w) => w.length >= 3 && !STOPWORDS.has(w))
37
+ .map((w) => (w.length > 4 && w.endsWith('s') ? w.slice(0, -1) : w));
38
+ }
39
+
40
+ // Rank a goal's UNSATISFIED criteria against a candidate task. Title terms weigh
41
+ // 2, description-only terms weigh 1. Returns a new array sorted best-first, each
42
+ // { id, criterion_id, criterion_md, score, matched_terms }. Ties break by
43
+ // ascending numeric id so the output is stable. Options:
44
+ // limit — cap the number of suggestions returned (default 3)
45
+ // includeZero — keep criteria with no term overlap (default false: drop them,
46
+ // so "no confident suggestion" returns [] rather than noise)
47
+ function suggestCriteria(criteria, candidate = {}, opts = {}) {
48
+ const { title = '', description = '' } = candidate;
49
+ const { limit = 3, includeZero = false } = opts;
50
+
51
+ // Build the weighted term map: description terms at 1, title terms override to
52
+ // 2 (a term in both is a title term — the stronger signal wins).
53
+ const weights = new Map();
54
+ for (const t of tokenize(description)) weights.set(t, 1);
55
+ for (const t of tokenize(title)) weights.set(t, 2);
56
+
57
+ const ranked = (Array.isArray(criteria) ? criteria : [])
58
+ .filter((c) => c && !c.satisfied)
59
+ .map((c) => {
60
+ const critTerms = new Set(tokenize(c.criterion_md));
61
+ let score = 0;
62
+ const matched = [];
63
+ for (const [term, w] of weights) {
64
+ if (critTerms.has(term)) { score += w; matched.push(term); }
65
+ }
66
+ return {
67
+ id: c.id,
68
+ criterion_id: c.criterion_id,
69
+ criterion_md: c.criterion_md,
70
+ score,
71
+ matched_terms: matched.sort(),
72
+ };
73
+ })
74
+ .sort((a, b) => (b.score - a.score) || (Number(a.id) - Number(b.id)));
75
+
76
+ const kept = includeZero ? ranked : ranked.filter((r) => r.score > 0);
77
+ return kept.slice(0, Math.max(0, limit));
78
+ }
79
+
80
+ module.exports = { suggestCriteria, tokenize };
@@ -1665,6 +1665,43 @@ async function listTasks({ versionId, status, kind, discipline, goalId } = {}) {
1665
1665
  // never can. This is a HARD control (a JS post-filter on the fit query), not a
1666
1666
  // soft prompt instruction. Backstops the SKILL.md fencing (layer 1).
1667
1667
 
1668
+ // task 2181 (incident, blocker 74): these are the EXACT bodies of the
1669
+ // claimable_tasks and tasks_session_fit views (migration
1670
+ // core_190_rebuild_claimable_task_views.sql), inlined as subqueries so the
1671
+ // claimable feed reads base tables (ACCESS SHARE only) instead of the views.
1672
+ // A detached backend queued an ACCESS EXCLUSIVE DDL on the views, which parks
1673
+ // every view SELECT behind it in the lock queue — the hall's primary feed hung
1674
+ // indefinitely while raw-table reads stayed fast. Postgres inlines simple
1675
+ // views into the same plan anyway, so this is semantics- and plan-identical;
1676
+ // it just stops the query's LOCKING from referencing the view relations.
1677
+ // If the view bodies change in a future migration, change these in lockstep
1678
+ // (or fold back to the views once per-view DDL contention is a solved problem).
1679
+ const CLAIMABLE_SQL = `
1680
+ SELECT t.*, pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
1681
+ FROM tasks t
1682
+ WHERE t.status = 'ready'
1683
+ AND t.title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
1684
+ AND NOT EXISTS (
1685
+ SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id
1686
+ )
1687
+ AND NOT EXISTS (
1688
+ SELECT 1 FROM claims c
1689
+ JOIN tasks t2 ON t2.id = c.task_id
1690
+ WHERE c.released_at IS NULL
1691
+ AND t2.touches && t.touches
1692
+ )`;
1693
+ const SESSION_FIT_SQL = `
1694
+ SELECT t.id,
1695
+ CASE
1696
+ WHEN t.automation_tag = 'overnight-safe' AND COALESCE(t.manual_degree, 5) <= 2
1697
+ AND NOT EXISTS (SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id)
1698
+ THEN ARRAY['autonomous','check-in','active']
1699
+ WHEN t.automation_tag = 'live-only' OR COALESCE(t.manual_degree, 5) >= 4
1700
+ THEN ARRAY['active']
1701
+ ELSE ARRAY['check-in','active']
1702
+ END AS fits_session_modes
1703
+ FROM tasks t`;
1704
+
1668
1705
  async function listClaimableTasks({ versionId = null, builderId = null, discipline = null, fit = null } = {}) {
1669
1706
  const params = [];
1670
1707
  const where = [];
@@ -1717,12 +1754,12 @@ async function listClaimableTasks({ versionId = null, builderId = null, discipli
1717
1754
  END
1718
1755
  )`);
1719
1756
  }
1720
- // Autonomy / parallel-eligibility filter. Joining tasks_session_fit (also a
1721
- // SELECT t.* view) makes bare column names like priority/created_at ambiguous,
1722
- // so the ORDER BY below qualifies them with v. unconditionally.
1757
+ // Autonomy / parallel-eligibility filter. Joining the session-fit relation
1758
+ // (also a SELECT t.* shape) makes bare column names like priority/created_at
1759
+ // ambiguous, so the ORDER BY below qualifies them with v. unconditionally.
1723
1760
  let fitJoin = '';
1724
1761
  if (fit === 'autonomous' || fit === 'parallel') {
1725
- fitJoin = ` JOIN tasks_session_fit sf ON sf.id = v.id`;
1762
+ fitJoin = ` JOIN (${SESSION_FIT_SQL}) sf ON sf.id = v.id`;
1726
1763
  where.push(`'autonomous' = ANY(sf.fits_session_modes)`);
1727
1764
  if (fit === 'parallel') {
1728
1765
  where.push(`v.parallel_safe = true`);
@@ -1733,7 +1770,7 @@ async function listClaimableTasks({ versionId = null, builderId = null, discipli
1733
1770
  // recent actuals; ordering on the raw estimate puts oversized tasks at the
1734
1771
  // bottom even when their actual time-to-ship is short. See migration 015.
1735
1772
  const sql =
1736
- `SELECT v.* FROM claimable_tasks v` + fitJoin +
1773
+ `SELECT v.* FROM (${CLAIMABLE_SQL}) v` + fitJoin +
1737
1774
  (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
1738
1775
  ` ORDER BY v.priority NULLS LAST,
1739
1776
  COALESCE(v.est_minutes_calibrated, v.est_minutes) NULLS LAST,
@@ -26,6 +26,7 @@ const api = require('../../../src/module-api');
26
26
  const auth = api;
27
27
  const db = require('../db');
28
28
  const hierarchy = require('../hierarchy');
29
+ const { suggestCriteria } = require('../criterion-suggest');
29
30
  const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, TASK_FIELD_BOUNDS } = require('../task-classifier');
30
31
  const { validateOrRespond, parseId, LIMITS } = api;
31
32
  const { isModuleKey, scopeIncludesProtected, moduleKeys } = api.moduleScopeMap;
@@ -548,6 +549,31 @@ module.exports = function buildGoalsRouter() {
548
549
  }
549
550
  });
550
551
 
552
+ // GET /goals/:id/suggest-criteria?title=&description=&limit= — task 2165.
553
+ // Rank THIS goal's unsatisfied criteria by lexical overlap with a candidate
554
+ // task, so a create flow can SUGGEST which done-when criterion to link (the
555
+ // author confirms/overrides — nothing is auto-linked). Read-only + advisory;
556
+ // rank: any-builder (same as GET /goals/:id).
557
+ router.get('/goals/:id/suggest-criteria', auth.requireBuilder, async (req, res) => {
558
+ const id = parseId(req, res, { code: 'bad_goal_id' });
559
+ if (id === null) return;
560
+ try {
561
+ const goal = await db.getGoal(id);
562
+ if (!goal) return res.fail('goal_not_found', 404);
563
+ const criteria = await db.listCriteriaForGoal(id);
564
+ const limit = Math.min(10, Math.max(1, Number(req.query.limit) || 3));
565
+ const suggestions = suggestCriteria(
566
+ criteria,
567
+ { title: req.query.title, description: req.query.description },
568
+ { limit }
569
+ );
570
+ return res.json({ goal_id: goal.id, suggestions });
571
+ } catch (err) {
572
+ console.error('[gds] GET /goals/:id/suggest-criteria', err);
573
+ return res.fail('fetch_failed', { status: 500, message: 'internal error' });
574
+ }
575
+ });
576
+
551
577
  // POST /goals/:id/join — join an open goal.
552
578
  // rank: any-builder — open join for a NORMAL goal. A PROTECTED-scope goal is
553
579
  // Archon-mediated: a non-Archon cannot self-join (an Archon admits them via
@@ -7,6 +7,7 @@ const api = require('../../../src/module-api');
7
7
  const auth = api;
8
8
  const db = require('../db');
9
9
  const hierarchy = require('../hierarchy');
10
+ const { suggestCriteria } = require('../criterion-suggest');
10
11
  // grading carved to modules/grading/ (BV1.R73 / ADR 0091 §4): the lifecycle no
11
12
  // longer imports the grader — it resolves the `grade` kernel PORT at grade time
12
13
  // (seams.resolveOptional('grade'), in POST /tasks/:id/grade below).
@@ -689,11 +690,31 @@ module.exports = function buildTasksRouter() {
689
690
  // task 1957: credits_reward is an ESTIMATE, not a guaranteed payout. The hall
690
691
  // renders it as "<Currency> Est." until the task settles (confirmed/shipped),
691
692
  // then switches to the real ledger figure (credits_awarded from the task read).
693
+ // task 2165 — the task landed under a goal but the caller linked no
694
+ // criterion → SUGGEST the most likely done-when criterion(s) for the author
695
+ // to confirm (they link via POST /tasks/:id/criteria). Advisory only, never
696
+ // auto-linked ("suggest, human picks"). Best-effort: a suggestion hiccup
697
+ // must not fail an otherwise-successful create. The general catch-all goal
698
+ // usually has no criteria, so this is naturally empty there.
699
+ let suggested_criteria = [];
700
+ if (criteria.length === 0 && task.goal_id != null) {
701
+ try {
702
+ const goalCriteria = await db.listCriteriaForGoal(task.goal_id);
703
+ suggested_criteria = suggestCriteria(
704
+ goalCriteria,
705
+ { title: body.title, description: body.description },
706
+ { limit: 3 }
707
+ );
708
+ } catch (e) {
709
+ console.error('[gds] POST /tasks suggest-criteria (non-blocking)', e && e.message);
710
+ }
711
+ }
692
712
  res.status(201).json({
693
713
  task,
694
714
  credits_reward: task.credits_reward,
695
715
  needs_migration: needsMigration,
696
716
  criteria,
717
+ suggested_criteria,
697
718
  });
698
719
  } catch (err) {
699
720
  console.error('[gds] POST /tasks', err);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.17.2",
3
+ "version": "1.17.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.17.2",
9
+ "version": "1.17.3",
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.17.2",
3
+ "version": "1.17.3",
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",