@bongos/core 1.20.10 → 1.20.12
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 +45 -35
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +50 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +60 -52
- package/docs/copy-registry.json +126 -54
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +131 -119
- package/migrations/core_264_claimable_respects_dependencies.sql +115 -0
- package/modules/hall-ui/public/panel.css +4 -4
- package/modules/hall-ui/public/settings-specialities.js +139 -2
- package/modules/hall-ui/public/settings.css +26 -0
- package/modules/hall-ui/public/settings.html +4 -0
- package/modules/hall-ui/public/settings.states.json +1 -0
- package/modules/hall-ui/records/panel-family.md +2 -0
- package/modules/lifecycle/db-tasks.js +42 -1
- package/modules/specialities/routes/specialities.js +16 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/hall-preview/server.js +4 -0
- package/src/bongos/optimizer.js +26 -1
- package/src/module-api.js +1 -1
- package/tests/claimable_blocker_status.mjs +160 -3
- package/tests/speciality_offer_ui.mjs +185 -0
- package/tests/speciality_skills.mjs +9 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
-- core_264 — claimable_tasks must also consult the DEPENDENCY graph (task
|
|
2
|
+
-- 1004190, finding #60 of the task 1000379 audit).
|
|
3
|
+
--
|
|
4
|
+
-- THE DEFECT. core_239's view filters on status, smoke titles, open blockers and
|
|
5
|
+
-- touches-overlap — and never once on `dependencies`. So a task sitting at
|
|
6
|
+
-- 'ready' whose predecessor has not shipped is ADVERTISED by /builder-start, the
|
|
7
|
+
-- optimizer and every claimable feed, and only `POST /claims` refuses it
|
|
8
|
+
-- (DEPS_NOT_SHIPPED, step 10 of the refusal ladder in modules/lifecycle/CLAUDE.md).
|
|
9
|
+
-- That is the "feed lies" family: the queue offers work the server will not let
|
|
10
|
+
-- you take. core_239 named this gap by number in its own header and deferred it
|
|
11
|
+
-- deliberately; this is the deferred half.
|
|
12
|
+
--
|
|
13
|
+
-- HOW A 'ready' ROW GETS UNSHIPPED DEPS AT ALL. The migration-163 auto-promotion
|
|
14
|
+
-- triggers gate backlog -> ready on task_is_fully_unblocked, so the common path
|
|
15
|
+
-- cannot produce one. The paths that CAN: a hand-promoted row
|
|
16
|
+
-- (POST /tasks/:id/promote), a dependency edge ADDED after the task reached
|
|
17
|
+
-- 'ready' (nothing demotes it), and a dependency target that leaves 'shipped'
|
|
18
|
+
-- later. db-claims.js calls its own dep check "the second line of defence" for
|
|
19
|
+
-- exactly these; the feed had no first line at all.
|
|
20
|
+
--
|
|
21
|
+
-- WHICH PREDICATE, AND WHY NOT task_is_fully_unblocked. There are two dependency
|
|
22
|
+
-- predicates in this codebase and they disagree on ONE case:
|
|
23
|
+
--
|
|
24
|
+
-- unsatisfiedDeps (modules/lifecycle/db-deps-criteria.js) — what POST /claims
|
|
25
|
+
-- actually runs. Each arm JOINs the target table, so an edge whose target row
|
|
26
|
+
-- no longer exists produces no row and does not gate.
|
|
27
|
+
-- task_is_fully_unblocked (core_245) — what the promotion triggers run. Its
|
|
28
|
+
-- header states the opposite reading: an orphaned `to` "counts as un-done ->
|
|
29
|
+
-- task stays blocked (fail-closed)".
|
|
30
|
+
--
|
|
31
|
+
-- Calling task_is_fully_unblocked here would have been the shorter line, and it
|
|
32
|
+
-- would have re-created core_239's bug inverted: a task with one orphaned edge
|
|
33
|
+
-- would vanish from the feed while POST /claims cheerfully accepted it —
|
|
34
|
+
-- workable by id, unfindable in the queue, permanently, with no blocker row to
|
|
35
|
+
-- explain it. The DONE-WHEN of this task is that the view and the claim path
|
|
36
|
+
-- AGREE, so the view mirrors unsatisfiedDeps' three UNION arms verbatim, not the
|
|
37
|
+
-- promotion predicate. Fail-closed is the right default when the two gates are
|
|
38
|
+
-- answering different questions; here they are answering the SAME question and
|
|
39
|
+
-- matching beats hedging. (The blocker clause below is untouched and stays
|
|
40
|
+
-- fail-closed — that one IS core_239's own question.)
|
|
41
|
+
--
|
|
42
|
+
-- ABANDONED STAYS GATING. `x.status <> 'shipped'` counts 'abandoned' as un-done,
|
|
43
|
+
-- so a task behind a cut prerequisite is now hidden from the feed rather than
|
|
44
|
+
-- offered and refused. That is the owner ruling of 2026-08-21 (modules/lifecycle/
|
|
45
|
+
-- CLAUDE.md) applied consistently, NOT finding #61: nothing here skips abandoned,
|
|
46
|
+
-- and the loud surfaces the ruling made load-bearing are untouched —
|
|
47
|
+
-- `scripts/gds/audit-deps.js --stranded`, `/backlog-review`'s depGated bucket and
|
|
48
|
+
-- the dead_deps refusal hint all read the dependency graph directly, never this
|
|
49
|
+
-- view.
|
|
50
|
+
--
|
|
51
|
+
-- THE THREE OTHER COPIES. The same view body is inlined as CLAIMABLE_SQL in
|
|
52
|
+
-- modules/lifecycle/db-tasks.js and inside src/bongos/optimizer.js's query (task
|
|
53
|
+
-- 2181: raw-table reads so the feed cannot park behind a view DDL lock). core_239
|
|
54
|
+
-- changed the view alone and the feed did not move for the whole life of task
|
|
55
|
+
-- 1003913. Both copies carry this clause as of this change, and
|
|
56
|
+
-- tests/claimable_blocker_status.mjs pins all three against each other.
|
|
57
|
+
--
|
|
58
|
+
-- COST, AND WHY THIS SHIPS NO INDEX. This is the /builder-start hot path, so the
|
|
59
|
+
-- question is fair and the answer is that every lookup the clause makes is already
|
|
60
|
+
-- covered — which is why there is no index migration beside this one:
|
|
61
|
+
--
|
|
62
|
+
-- the three from_kind arms -> idx_dependencies_from (from_kind, from_id), migration 163.
|
|
63
|
+
-- All three probe the SAME two columns, so the planner
|
|
64
|
+
-- can BitmapOr three index scans rather than seq-scan.
|
|
65
|
+
-- the criterion arm's IN -> idx_task_criteria_task (task_id), migration 055.
|
|
66
|
+
-- the three doneness EXISTS -> the primary keys of tasks / done_when_criteria / goals.
|
|
67
|
+
--
|
|
68
|
+
-- `d.to_kind` / `d.to_id` appear only as filters on rows already found through the
|
|
69
|
+
-- from-side index, never as a lookup key, so idx_dependencies_to is not on this path.
|
|
70
|
+
-- Asserted, not just claimed: tests/claimable_blocker_status.mjs fails if either
|
|
71
|
+
-- named index leaves migrations/, because a comment cannot notice that.
|
|
72
|
+
--
|
|
73
|
+
-- Idempotent: DROP VIEW IF EXISTS + CREATE, the shape core_231 and core_239 used.
|
|
74
|
+
-- The column list stays the `t.*` wildcard plus the calibrated-minutes column,
|
|
75
|
+
-- byte-identical to core_239 (core_227's header records six columns that silently
|
|
76
|
+
-- never reached the feed because a rewrite spelled the list out instead).
|
|
77
|
+
|
|
78
|
+
BEGIN;
|
|
79
|
+
|
|
80
|
+
DROP VIEW IF EXISTS claimable_tasks;
|
|
81
|
+
CREATE VIEW claimable_tasks AS
|
|
82
|
+
SELECT t.*,
|
|
83
|
+
pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
|
|
84
|
+
FROM tasks t
|
|
85
|
+
WHERE t.status = 'ready'
|
|
86
|
+
AND t.title NOT LIKE '\_\_smoke\_\_%' ESCAPE '\'
|
|
87
|
+
AND NOT EXISTS (
|
|
88
|
+
SELECT 1
|
|
89
|
+
FROM task_blockers tb
|
|
90
|
+
LEFT JOIN blockers b ON b.id = tb.blocker_id
|
|
91
|
+
WHERE tb.task_id = t.id
|
|
92
|
+
AND (b.id IS NULL OR b.status = 'open')
|
|
93
|
+
)
|
|
94
|
+
AND NOT EXISTS (
|
|
95
|
+
SELECT 1
|
|
96
|
+
FROM dependencies d
|
|
97
|
+
WHERE ((d.from_kind = 'task' AND d.from_id = t.id)
|
|
98
|
+
OR (d.from_kind = 'goal' AND d.from_id = t.goal_id)
|
|
99
|
+
OR (d.from_kind = 'criterion' AND d.from_id IN (
|
|
100
|
+
SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id)))
|
|
101
|
+
AND ((d.to_kind = 'task' AND EXISTS (SELECT 1 FROM tasks x WHERE x.id = d.to_id AND x.status <> 'shipped'))
|
|
102
|
+
OR (d.to_kind = 'criterion' AND EXISTS (SELECT 1 FROM done_when_criteria x WHERE x.id = d.to_id AND x.satisfied = false))
|
|
103
|
+
OR (d.to_kind = 'goal' AND EXISTS (SELECT 1 FROM goals x WHERE x.id = d.to_id AND x.status <> 'achieved')))
|
|
104
|
+
)
|
|
105
|
+
AND NOT EXISTS (
|
|
106
|
+
SELECT 1 FROM claims c
|
|
107
|
+
JOIN tasks t2 ON t2.id = c.task_id
|
|
108
|
+
WHERE c.released_at IS NULL
|
|
109
|
+
AND t2.touches && t.touches
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
COMMIT;
|
|
113
|
+
|
|
114
|
+
INSERT INTO schema_migrations (version) VALUES ('core_264_claimable_respects_dependencies')
|
|
115
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -252,7 +252,7 @@ button.craft-save { display: inline-flex; align-items: center; justify-content:
|
|
|
252
252
|
user-select: none;
|
|
253
253
|
}
|
|
254
254
|
.chip-ctl:has(input:checked), .craft-chip[data-on="1"] { color: var(--ink); border-color: var(--rule); }
|
|
255
|
-
.chip-ctl input, .craft-pref {
|
|
255
|
+
.chip-ctl input, .craft-pref, .speciality-offer__list input[type="checkbox"] {
|
|
256
256
|
appearance: none;
|
|
257
257
|
-webkit-appearance: none;
|
|
258
258
|
position: relative;
|
|
@@ -262,7 +262,7 @@ button.craft-save { display: inline-flex; align-items: center; justify-content:
|
|
|
262
262
|
flex: none;
|
|
263
263
|
cursor: pointer;
|
|
264
264
|
}
|
|
265
|
-
.chip-ctl input::before, .craft-pref::before {
|
|
265
|
+
.chip-ctl input::before, .craft-pref::before, .speciality-offer__list input[type="checkbox"]::before {
|
|
266
266
|
content: "";
|
|
267
267
|
position: absolute;
|
|
268
268
|
inset: 5px;
|
|
@@ -272,8 +272,8 @@ button.craft-save { display: inline-flex; align-items: center; justify-content:
|
|
|
272
272
|
border-radius: 4px;
|
|
273
273
|
background: transparent;
|
|
274
274
|
}
|
|
275
|
-
.chip-ctl input:checked::before, .craft-pref:checked::before { background: var(--accent); border-color: var(--accent); }
|
|
276
|
-
.chip-ctl input:checked::after, .craft-pref:checked::after {
|
|
275
|
+
.chip-ctl input:checked::before, .craft-pref:checked::before, .speciality-offer__list input[type="checkbox"]:checked::before { background: var(--accent); border-color: var(--accent); }
|
|
276
|
+
.chip-ctl input:checked::after, .craft-pref:checked::after, .speciality-offer__list input[type="checkbox"]:checked::after {
|
|
277
277
|
content: "";
|
|
278
278
|
position: absolute;
|
|
279
279
|
left: 9px;
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
|
|
27
27
|
// `walk` is the skills walkthrough in progress, or null (task 1004073):
|
|
28
28
|
// { id, data, step, decisions, saving }. See walkHtml below.
|
|
29
|
-
|
|
29
|
+
// `offerId` / `offerChoices`: the owner's "what it offers" editor that is open,
|
|
30
|
+
// and its rendered choices once the instance's skills have loaded (task 1004404).
|
|
31
|
+
const state = { rows: [], suggestions: [], openId: null, busy: false, walk: null, offerId: null, offerChoices: null };
|
|
30
32
|
|
|
31
33
|
const el = (id) => document.getElementById(id);
|
|
32
34
|
const statusLine = (msg) => { const s = el('specialities-status'); if (s) s.textContent = msg || ''; };
|
|
@@ -211,12 +213,112 @@
|
|
|
211
213
|
focusWalk();
|
|
212
214
|
}
|
|
213
215
|
|
|
216
|
+
// ── WHAT A SPECIALITY OFFERS (task 1004404; ADR 0310 §1) ──────────────────
|
|
217
|
+
//
|
|
218
|
+
// The OWNER's half: which of this instance's skills the speciality puts on
|
|
219
|
+
// its menu. Until this, only the API could set it, so nothing made in the hall
|
|
220
|
+
// offered anything and the walkthrough above never appeared. Each choice shows
|
|
221
|
+
// the skill's plain description — the same words the walkthrough will show the
|
|
222
|
+
// people who adopt it — so the owner is choosing what to teach, not ticking
|
|
223
|
+
// names. Offering turns nothing on for anyone: every adopter still starts with
|
|
224
|
+
// all of them off and decides one at a time.
|
|
225
|
+
//
|
|
226
|
+
// The choices come from GET /specialities/installed-skills, so only skills
|
|
227
|
+
// this instance has are offered; the server's unknown_skill check on POST/PATCH
|
|
228
|
+
// stays the rule for anything a stale page sends.
|
|
229
|
+
|
|
230
|
+
let installedList = null; // fetched once per page, on first need
|
|
231
|
+
async function installedSkillList() {
|
|
232
|
+
if (!installedList) {
|
|
233
|
+
const data = await call('GET', '/specialities/installed-skills');
|
|
234
|
+
installedList = (data && data.skills) || [];
|
|
235
|
+
}
|
|
236
|
+
return installedList;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function offerChoicesHtml(all, chosen) {
|
|
240
|
+
const picked = new Set(chosen || []);
|
|
241
|
+
const gone = (chosen || []).filter((n) => !all.some((k) => k.name === n));
|
|
242
|
+
return `
|
|
243
|
+
<p class="prow__hint">
|
|
244
|
+
Offering a skill turns nothing on. Everyone who uses this speciality is shown
|
|
245
|
+
each one and chooses for themselves — all start off.
|
|
246
|
+
</p>
|
|
247
|
+
<label class="speciality-field">
|
|
248
|
+
<span>Find a skill</span>
|
|
249
|
+
<input type="search" class="speciality-offer__filter" placeholder="Type to narrow the list" />
|
|
250
|
+
</label>
|
|
251
|
+
<ul class="speciality-offer__list">
|
|
252
|
+
${all.map((k) => `
|
|
253
|
+
<li data-find="${escapeHtml(`${k.name} ${k.does || ''}`.toLowerCase())}">
|
|
254
|
+
<label class="speciality-field speciality-field--check">
|
|
255
|
+
<input type="checkbox" name="skill" value="${escapeHtml(k.name)}" ${picked.has(k.name) ? 'checked' : ''} />
|
|
256
|
+
<span>
|
|
257
|
+
<strong>${escapeHtml(k.name)}</strong> — ${escapeHtml(k.does || 'Its author gave no description.')}
|
|
258
|
+
<small>${escapeHtml(k.cost || '')}</small>
|
|
259
|
+
</span>
|
|
260
|
+
</label>
|
|
261
|
+
</li>`).join('')}
|
|
262
|
+
</ul>
|
|
263
|
+
${gone.length ? `<p class="prow__hint">No longer on this project, so saving drops it from the offer: ${gone.map((n) => `<code>${escapeHtml(n)}</code>`).join(', ')}.</p>` : ''}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const chosenSkills = (form) => [...form.querySelectorAll('input[name="skill"]:checked')].map((i) => i.value);
|
|
267
|
+
|
|
268
|
+
function narrowOffer(input) {
|
|
269
|
+
const q = input.value.trim().toLowerCase();
|
|
270
|
+
const list = input.closest('form') && input.closest('form').querySelector('.speciality-offer__list');
|
|
271
|
+
if (!list) return;
|
|
272
|
+
for (const li of list.children) li.hidden = q !== '' && !String(li.dataset.find || '').includes(q);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function offerFormHtml(s) {
|
|
276
|
+
return `
|
|
277
|
+
<form class="speciality-offer" data-id="${escapeHtml(String(s.id))}">
|
|
278
|
+
<h4 class="speciality-create__h">What ${escapeHtml(s.name)} offers</h4>
|
|
279
|
+
<div class="speciality-offer__choices">${state.offerChoices || '<p class="prow__hint">Loading this project’s skills…</p>'}</div>
|
|
280
|
+
<div class="speciality-actions">
|
|
281
|
+
<button type="submit" class="btn" ${state.offerChoices ? '' : 'disabled'}>Save what it offers</button>
|
|
282
|
+
</div>
|
|
283
|
+
</form>`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function openOffer(id) {
|
|
287
|
+
if (String(state.offerId) === String(id)) { state.offerId = null; render(); return; }
|
|
288
|
+
state.offerId = String(id);
|
|
289
|
+
state.offerChoices = null;
|
|
290
|
+
state.openId = null;
|
|
291
|
+
state.walk = null;
|
|
292
|
+
render();
|
|
293
|
+
try {
|
|
294
|
+
const all = await installedSkillList();
|
|
295
|
+
const row = state.rows.find((r) => String(r.id) === String(id));
|
|
296
|
+
if (state.offerId !== String(id) || !row) return;
|
|
297
|
+
state.offerChoices = offerChoicesHtml(all, row.skills || []);
|
|
298
|
+
render();
|
|
299
|
+
} catch (err) { statusLine(`Could not load this project’s skills — ${err.message}`); }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The create form's picker fills the first time it is opened, so a builder
|
|
303
|
+
// who never offers a skill never pays for the list.
|
|
304
|
+
async function fillCreateOffer(host) {
|
|
305
|
+
if (host.dataset.filled) return;
|
|
306
|
+
host.dataset.filled = '1';
|
|
307
|
+
try {
|
|
308
|
+
host.innerHTML = offerChoicesHtml(await installedSkillList(), []);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
delete host.dataset.filled;
|
|
311
|
+
host.innerHTML = `<p class="prow__hint">Could not load this project’s skills — ${escapeHtml(err.message)}</p>`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
214
315
|
function cardHtml(s) {
|
|
215
316
|
const trained = Number(s.documentCount || 0);
|
|
216
317
|
const learned = Number(s.learningCount || 0);
|
|
217
318
|
const open = String(state.openId) === String(s.id);
|
|
218
319
|
const offered = Array.isArray(s.skills) ? s.skills.length : 0;
|
|
219
320
|
const walking = state.walk && state.walk.id === String(s.id);
|
|
321
|
+
const offering = s.mine && String(state.offerId) === String(s.id);
|
|
220
322
|
return `
|
|
221
323
|
<li class="prow speciality-card" data-id="${escapeHtml(String(s.id))}">
|
|
222
324
|
<div class="prow__main">
|
|
@@ -237,11 +339,13 @@
|
|
|
237
339
|
<div class="prow__actions">
|
|
238
340
|
${s.active && offered && !walking ? `<button type="button" class="btn btn--quiet" data-act="walk" data-id="${escapeHtml(String(s.id))}">Choose its skills</button>` : ''}
|
|
239
341
|
${s.mine ? `<button type="button" class="btn btn--quiet" data-act="train" data-id="${escapeHtml(String(s.id))}">${open ? 'Close' : 'Train'}</button>` : ''}
|
|
342
|
+
${s.mine ? `<button type="button" class="btn btn--quiet" data-act="offer" data-id="${escapeHtml(String(s.id))}" aria-expanded="${offering}">${offering ? 'Close' : 'Offered skills'}</button>` : ''}
|
|
240
343
|
${s.active
|
|
241
344
|
? `<button type="button" class="btn btn--quiet" data-act="abandon" data-id="${escapeHtml(String(s.id))}">Stop using</button>`
|
|
242
345
|
: `<button type="button" class="btn" data-act="adopt" data-id="${escapeHtml(String(s.id))}" ${s.adoptable ? '' : 'disabled'}>Use this</button>`}
|
|
243
346
|
</div>
|
|
244
347
|
${open ? trainHtml(s) : ''}
|
|
348
|
+
${offering ? offerFormHtml(s) : ''}
|
|
245
349
|
${walking ? walkHtml() : ''}
|
|
246
350
|
</li>`;
|
|
247
351
|
}
|
|
@@ -388,11 +492,28 @@
|
|
|
388
492
|
if (!btn) return;
|
|
389
493
|
const { act: action, id } = btn.dataset;
|
|
390
494
|
if (action === 'walk') { openWalk(id); return; }
|
|
391
|
-
if (action === '
|
|
495
|
+
if (action === 'offer') { openOffer(id); return; }
|
|
496
|
+
if (action === 'train') { state.openId = String(state.openId) === String(id) ? null : id; state.walk = null; state.offerId = null; render(); return; }
|
|
392
497
|
act(action, id);
|
|
393
498
|
});
|
|
394
499
|
|
|
500
|
+
host.addEventListener('input', (e) => {
|
|
501
|
+
if (e.target.matches('.speciality-offer__filter')) narrowOffer(e.target);
|
|
502
|
+
});
|
|
503
|
+
|
|
395
504
|
host.addEventListener('submit', async (e) => {
|
|
505
|
+
const offer = e.target.closest('form.speciality-offer');
|
|
506
|
+
if (offer) {
|
|
507
|
+
e.preventDefault();
|
|
508
|
+
const skills = chosenSkills(offer);
|
|
509
|
+
try {
|
|
510
|
+
await call('PATCH', `/specialities/${offer.dataset.id}`, { skills });
|
|
511
|
+
state.offerId = null;
|
|
512
|
+
toast(skills.length ? `Saved — it offers ${skills.length} skill${skills.length === 1 ? '' : 's'}.` : 'Saved — it offers no skills.');
|
|
513
|
+
await load();
|
|
514
|
+
} catch (err) { statusLine(`Could not save what it offers — ${err.message}`); }
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
396
517
|
const form = e.target.closest('form.speciality-train');
|
|
397
518
|
if (!form) return;
|
|
398
519
|
e.preventDefault();
|
|
@@ -426,8 +547,10 @@
|
|
|
426
547
|
contract_md: create.elements.contract_md.value.trim(),
|
|
427
548
|
visibility: create.elements.visibility.value,
|
|
428
549
|
sellable: create.elements.sellable.checked,
|
|
550
|
+
skills: chosenSkills(create),
|
|
429
551
|
});
|
|
430
552
|
create.reset();
|
|
553
|
+
for (const li of create.querySelectorAll('.speciality-offer__list > li')) li.hidden = false;
|
|
431
554
|
toast('Speciality created.');
|
|
432
555
|
await load();
|
|
433
556
|
} catch (err) { statusLine(err.message); }
|
|
@@ -445,7 +568,21 @@
|
|
|
445
568
|
};
|
|
446
569
|
vis.addEventListener('change', sync);
|
|
447
570
|
sync();
|
|
571
|
+
|
|
572
|
+
// The skills picker (task 1004404): filled on first open, narrowed as you type.
|
|
573
|
+
const offerBox = el('speciality-create-offer');
|
|
574
|
+
const offerHost = el('speciality-create-skills');
|
|
575
|
+
if (offerBox && offerHost) offerBox.addEventListener('toggle', () => { if (offerBox.open) fillCreateOffer(offerHost); });
|
|
576
|
+
create.addEventListener('input', (e) => {
|
|
577
|
+
if (e.target.matches('.speciality-offer__filter')) narrowOffer(e.target);
|
|
578
|
+
});
|
|
448
579
|
}
|
|
580
|
+
|
|
581
|
+
// Enter in a "Find a skill" box narrows the list; it must not submit the
|
|
582
|
+
// form around it half-chosen.
|
|
583
|
+
document.addEventListener('keydown', (e) => {
|
|
584
|
+
if (e.key === 'Enter' && e.target && e.target.matches && e.target.matches('.speciality-offer__filter')) e.preventDefault();
|
|
585
|
+
});
|
|
449
586
|
}
|
|
450
587
|
|
|
451
588
|
function boot() {
|
|
@@ -199,6 +199,32 @@ select.voices__select { min-width: 140px; }
|
|
|
199
199
|
.speciality-field--check { flex-direction: row; align-items: flex-start; gap: 0.5rem; }
|
|
200
200
|
.speciality-field--check input { margin-top: 0.2rem; }
|
|
201
201
|
.speciality-actions { display: flex; gap: 0.5rem; }
|
|
202
|
+
/* What a speciality OFFERS (task 1004404): the owner's pick-list of this
|
|
203
|
+
instance's skills, each with its plain line. Sixty-odd rows, so the list
|
|
204
|
+
scrolls inside its own box rather than pushing the form's buttons off-screen;
|
|
205
|
+
the reading lines keep the measure. */
|
|
206
|
+
.speciality-field input[type='search'] { width: 100%; box-sizing: border-box; }
|
|
207
|
+
.speciality-offer,
|
|
208
|
+
.speciality-offer-box { flex-basis: 100%; display: flex; flex-direction: column; gap: 0.6rem; }
|
|
209
|
+
.speciality-offer { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--rule); }
|
|
210
|
+
.speciality-offer > *, .speciality-offer-box > * { max-width: var(--measure); }
|
|
211
|
+
.speciality-offer-box > summary { cursor: pointer; font-size: 0.85rem; min-height: 24px; padding-block: 2px; box-sizing: border-box; }
|
|
212
|
+
.speciality-offer-box > summary small { opacity: 0.7; }
|
|
213
|
+
.speciality-offer__choices, #speciality-create-skills { display: flex; flex-direction: column; gap: 0.6rem; }
|
|
214
|
+
.speciality-offer__list {
|
|
215
|
+
list-style: none;
|
|
216
|
+
margin: 0;
|
|
217
|
+
padding: 0 0.25rem 0 0;
|
|
218
|
+
display: flex;
|
|
219
|
+
flex-direction: column;
|
|
220
|
+
gap: 0.5rem;
|
|
221
|
+
max-height: 24rem;
|
|
222
|
+
overflow-y: auto;
|
|
223
|
+
overflow-x: hidden;
|
|
224
|
+
}
|
|
225
|
+
.speciality-offer__list li[hidden] { display: none; }
|
|
226
|
+
.speciality-offer__list strong { font-family: var(--font-mono); font-weight: 600; }
|
|
227
|
+
.speciality-offer__list .speciality-field--check > span { font-size: 0.85rem; line-height: 1.45; }
|
|
202
228
|
/* The adopted contract, shown in full before "Use this" — another builder's prose
|
|
203
229
|
reaching your sessions is the one thing here that must never be folded away. */
|
|
204
230
|
.speciality-contract { flex-basis: 100%; margin-top: 0.5rem; font-size: 0.85rem; }
|
|
@@ -259,6 +259,10 @@
|
|
|
259
259
|
<small>Records your decision only — there is no marketplace yet, and nothing is listed anywhere.</small>
|
|
260
260
|
</span>
|
|
261
261
|
</label>
|
|
262
|
+
<details id="speciality-create-offer" class="speciality-offer-box">
|
|
263
|
+
<summary>Skills it offers <small>(optional)</small></summary>
|
|
264
|
+
<div id="speciality-create-skills"><p class="prow__hint">Loading this project’s skills…</p></div>
|
|
265
|
+
</details>
|
|
262
266
|
<div class="speciality-actions">
|
|
263
267
|
<button type="submit" class="btn">Create speciality</button>
|
|
264
268
|
</div>
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"access": { "auth": true, "url": "/settings#access", "actions": [["wait", 900]], "expect": { "visible": ["#cli-reissue-btn", "#sessions-list", "#artkey-card"] } },
|
|
12
12
|
"preferences": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900]], "expect": { "visible": ["#wander-rows", "#render-rows", "#specialities-rows"] } },
|
|
13
13
|
"skills-walkthrough": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900], ["click", "#specialities-rows button[data-act=\"adopt\"]"], ["wait", 700]], "expect": { "visible": [".skill-walk", ".skill-walk__facts", "button[data-walk=\"on\"]", "button[data-walk=\"off\"]"] } },
|
|
14
|
+
"offered-skills": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900], ["click", "#specialities-rows button[data-act=\"offer\"]"], ["wait", 700]], "expect": { "visible": ["form.speciality-offer", ".speciality-offer__list", ".speciality-offer__filter"] } },
|
|
14
15
|
"sound": { "auth": true, "url": "/settings#sound", "actions": [["wait", 900]], "expect": { "visible": ["#voices-table", "#sound-rows", "#event-sound-rows"] } },
|
|
15
16
|
"update": { "auth": true, "url": "/settings#software-update", "actions": [["wait", 1200]], "expect": { "visible": ["#software-update-h", "#su-body", "#su-update", ".su-versions"] } }
|
|
16
17
|
},
|
|
@@ -28,3 +28,5 @@ Also fixed here, as the board task handed forward: the two sub-24px links on Hom
|
|
|
28
28
|
- **Kit:** `settings.states.json` `update`, ALL CLEAN at 1440/390/320 × dark/light against the harness with the fixture `software-update.json` (a live reading for 1.19.1033). Tests: `tests/software_update.mjs`.
|
|
29
29
|
|
|
30
30
|
**Settings: adopting a speciality walks through its skills (task 1004073, ADR 0310 §3)** — the owner ruled an adopted speciality's skills start OFF "and you should walk through what comes with each". So adopting one that offers skills opens `.skill-walk` inside its card: one skill per step (what it does, when to reach for it, what it costs, what it needs, who can run it), "Leave it off" / "Turn it on" at EQUAL weight (plain `.pbtn`, no accent — the default is off, so the page must not lean toward on), Back, then a recap restating each skill in a line, then ONE `PUT` of the whole set. "Finish later" saves nothing. Focus moves to each new step heading. Two things found on the way: the specialities panel shipped `hidden` and nothing ever revealed it, so adoption was unreachable from the hall; and its contract `<summary>` measured 21px once visible (now 24). **Kit:** `settings.states.json` `skills-walkthrough` + `preferences`, ALL CLEAN at 1440/390/320 × dark/light against the harness, with fixtures `specialities*.json` built from the real skills on disk and canned answers for adopt and save. Tests: `tests/speciality_walkthrough_ui.mjs` (runs the real page script).
|
|
31
|
+
|
|
32
|
+
**Settings: a speciality's owner chooses what it offers (task 1004404, ADR 0310 §1)** — until this only the API could set an offer, so the walkthrough above never appeared for a speciality made in the hall. The create form gains a `<details>` "Skills it offers" picker (filled on first open, so a builder who never offers one never pays for the list), and an owned card gains "Offered skills", an inline `form.speciality-offer` like Train. Both list `GET /specialities/installed-skills` — only what this instance has — each row a checkbox with the skill's plain line and cost, the words adopters will see, under a line saying offering turns nothing on. A "Find a skill" box narrows the sixty-odd rows (Enter never submits), the list scrolls in its own box, an offered skill removed since is named and dropped on save rather than silently kept, and saving is ONE `PATCH { skills }`. **The checkboxes take the shared 24px rule in `panel.css`** (the `.chip-ctl input` / `.craft-pref` block, one selector added) rather than a fourth copy of the `::before` box; without it 128 targets measured 13px. **Kit:** `settings.states.json` `offered-skills` + `preferences`, ALL CLEAN at 1440/390/320 × dark/light, with fixtures `specialities__installed-skills.json` (the real 64 skills, explained) and an owned speciality in `specialities.json`, plus canned POST/PATCH answers. Tests: `tests/speciality_offer_ui.mjs` (runs the real page script).
|
|
@@ -883,11 +883,52 @@ const OPEN_BLOCKER_GUARD = `
|
|
|
883
883
|
AND (b.id IS NULL OR b.status = 'open')
|
|
884
884
|
)`;
|
|
885
885
|
|
|
886
|
+
// THE DEPENDENCY GUARD, spelled ONCE (task 1004190, finding #60).
|
|
887
|
+
//
|
|
888
|
+
// The sibling of OPEN_BLOCKER_GUARD, and it was missing entirely until now: the
|
|
889
|
+
// claimable feed filtered on status, smoke titles, open blockers and touches
|
|
890
|
+
// overlap, and never on `dependencies`. A task at 'ready' whose predecessor has
|
|
891
|
+
// not shipped was ADVERTISED here and then refused by POST /claims with
|
|
892
|
+
// DEPS_NOT_SHIPPED — the same "the feed lies" family core_239 fixed for blockers.
|
|
893
|
+
//
|
|
894
|
+
// THIS MIRRORS unsatisfiedDeps (db-deps-criteria.js), NOT task_is_fully_unblocked.
|
|
895
|
+
// The two predicates disagree on one case and the disagreement is deliberate on
|
|
896
|
+
// both sides: an edge whose target row no longer exists does NOT gate a claim
|
|
897
|
+
// (each of unsatisfiedDeps' arms JOINs the target, so an orphan produces no row)
|
|
898
|
+
// and DOES block promotion (core_245's header calls that fail-closed). Copying the
|
|
899
|
+
// promotion predicate into the feed would hide a task POST /claims would accept —
|
|
900
|
+
// core_239's bug inverted, and invisible to the builder looking for it. The feed
|
|
901
|
+
// must say exactly what the claim gate will do, so the three UNION arms of
|
|
902
|
+
// unsatisfiedDeps are reproduced here verbatim.
|
|
903
|
+
//
|
|
904
|
+
// `x.status <> 'shipped'` keeps 'abandoned' gating, per the owner ruling of
|
|
905
|
+
// 2026-08-21 (see ./CLAUDE.md). A task behind a cut prerequisite is now hidden
|
|
906
|
+
// rather than offered-and-refused; the surfaces that must stay LOUD about it read
|
|
907
|
+
// the dependency graph directly (audit-deps.js --stranded, /backlog-review, the
|
|
908
|
+
// dead_deps refusal hint) and never this feed.
|
|
909
|
+
//
|
|
910
|
+
// ONE query interpolates it, not two. SESSION_FIT_SQL is deliberately left alone:
|
|
911
|
+
// it is only ever JOINed onto this feed (here and in optimizer.js), so the guard
|
|
912
|
+
// already applies to every row it can see, and adding it there would change what
|
|
913
|
+
// the tasks_session_fit VIEW means with no migration saying so.
|
|
914
|
+
const UNSHIPPED_DEPS_GUARD = `
|
|
915
|
+
AND NOT EXISTS (
|
|
916
|
+
SELECT 1
|
|
917
|
+
FROM dependencies d
|
|
918
|
+
WHERE ((d.from_kind = 'task' AND d.from_id = t.id)
|
|
919
|
+
OR (d.from_kind = 'goal' AND d.from_id = t.goal_id)
|
|
920
|
+
OR (d.from_kind = 'criterion' AND d.from_id IN (
|
|
921
|
+
SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id)))
|
|
922
|
+
AND ((d.to_kind = 'task' AND EXISTS (SELECT 1 FROM tasks x WHERE x.id = d.to_id AND x.status <> 'shipped'))
|
|
923
|
+
OR (d.to_kind = 'criterion' AND EXISTS (SELECT 1 FROM done_when_criteria x WHERE x.id = d.to_id AND x.satisfied = false))
|
|
924
|
+
OR (d.to_kind = 'goal' AND EXISTS (SELECT 1 FROM goals x WHERE x.id = d.to_id AND x.status <> 'achieved')))
|
|
925
|
+
)`;
|
|
926
|
+
|
|
886
927
|
const CLAIMABLE_SQL = `
|
|
887
928
|
SELECT t.*, pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
|
|
888
929
|
FROM tasks t
|
|
889
930
|
WHERE t.status = 'ready'
|
|
890
|
-
AND t.title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'${OPEN_BLOCKER_GUARD}
|
|
931
|
+
AND t.title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'${OPEN_BLOCKER_GUARD}${UNSHIPPED_DEPS_GUARD}
|
|
891
932
|
AND NOT EXISTS (
|
|
892
933
|
SELECT 1 FROM claims c
|
|
893
934
|
JOIN tasks t2 ON t2.id = c.task_id
|
|
@@ -285,6 +285,22 @@ module.exports = function buildSpecialitiesRouter() {
|
|
|
285
285
|
res.json({ suggestions });
|
|
286
286
|
}));
|
|
287
287
|
|
|
288
|
+
// GET /specialities/installed-skills — every skill THIS instance has, each
|
|
289
|
+
// explained the way the adoption walkthrough explains it, so a speciality's
|
|
290
|
+
// owner can choose what it offers from the hall (task 1004404). The CHOICES
|
|
291
|
+
// only: the POST/PATCH `skills` check (unknown_skill) is still the rule, so a
|
|
292
|
+
// stale page that sends a name removed since is refused, not trusted. Nothing
|
|
293
|
+
// here is per-builder and the list is public knowledge of the install — every
|
|
294
|
+
// name is already materialised into each checkout's .claude/skills/.
|
|
295
|
+
// Mounted before '/specialities/:id' would capture it as an id. Read-only;
|
|
296
|
+
// rank: any builder.
|
|
297
|
+
router.get('/specialities/installed-skills', api.requireBuilder, asyncHandler('GET /specialities/installed-skills', async (_req, res) => {
|
|
298
|
+
const skills = [...installedSkills()]
|
|
299
|
+
.map(([name, entry]) => skillsLib.explainSkill({ name, ...entry }))
|
|
300
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
301
|
+
res.json({ skills });
|
|
302
|
+
}));
|
|
303
|
+
|
|
288
304
|
router.get('/specialities/:id', api.requireBuilder, asyncHandler('GET /specialities/:id', async (req, res) => {
|
|
289
305
|
const id = parseId(req, res, { code: 'bad_id' });
|
|
290
306
|
if (id === null) return;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.12",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.12",
|
|
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.20.
|
|
3
|
+
"version": "1.20.12",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -8112,5 +8112,17 @@
|
|
|
8112
8112
|
"id": "1004403",
|
|
8113
8113
|
"text": "When you adopt a speciality, each skill it offers is now explained in plain words: what it does, when you would use it, and what it costs, including which ones spend money. Before, those explanations were guessed from technica"
|
|
8114
8114
|
}
|
|
8115
|
+
],
|
|
8116
|
+
"1.20.11": [
|
|
8117
|
+
{
|
|
8118
|
+
"id": "1004404",
|
|
8119
|
+
"text": "You can now choose which skills your speciality offers from Settings, not just through the API. You can do it when you create the speciality or later from its card. Each skill is listed with a plain description and what it cos"
|
|
8120
|
+
}
|
|
8121
|
+
],
|
|
8122
|
+
"1.20.12": [
|
|
8123
|
+
{
|
|
8124
|
+
"id": "1004190",
|
|
8125
|
+
"text": "The task list no longer offers you work the system will refuse: a job waiting on another job that hasn't finished yet is now hidden until it's actually ready to start."
|
|
8126
|
+
}
|
|
8115
8127
|
]
|
|
8116
8128
|
}
|
|
@@ -164,6 +164,10 @@ const CANNED_WRITES = [
|
|
|
164
164
|
// opens the skills walkthrough and its recap can save in the harness.
|
|
165
165
|
['POST', /^specialities\/\d+\/adopt$/, 'specialities__9__adopt.post'],
|
|
166
166
|
['PUT', /^specialities\/\d+\/skills$/, 'specialities__9__skills.put'],
|
|
167
|
+
// the owner choosing what a speciality offers (task 1004404): on create, and
|
|
168
|
+
// afterwards from the card's "Offered skills" editor.
|
|
169
|
+
['POST', /^specialities$/, 'specialities.post'],
|
|
170
|
+
['PATCH', /^specialities\/\d+$/, 'specialities__12.patch'],
|
|
167
171
|
];
|
|
168
172
|
|
|
169
173
|
// A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
|
package/src/bongos/optimizer.js
CHANGED
|
@@ -52,6 +52,31 @@ const OPEN_BLOCKER_GUARD = `
|
|
|
52
52
|
AND (b.id IS NULL OR b.status = 'open')
|
|
53
53
|
)`;
|
|
54
54
|
|
|
55
|
+
// The dependency guard, once (task 1004190, finding #60). Same predicate as
|
|
56
|
+
// migration core_264 and as db-tasks.js's UNSHIPPED_DEPS_GUARD, duplicated ACROSS
|
|
57
|
+
// those files for the ADR 0083 reason above and deduplicated WITHIN this one.
|
|
58
|
+
//
|
|
59
|
+
// It mirrors unsatisfiedDeps (the claim gate), not task_is_fully_unblocked (the
|
|
60
|
+
// promotion predicate): the two disagree on an edge whose target row is gone, and
|
|
61
|
+
// the feed must say what POST /claims will actually do. Full rationale in
|
|
62
|
+
// core_264's header.
|
|
63
|
+
//
|
|
64
|
+
// Only the claimable body below interpolates it — the session-fit body is JOINed
|
|
65
|
+
// onto that body, so every row it can see is already guarded, and putting it there
|
|
66
|
+
// would change what the tasks_session_fit VIEW means with no migration saying so.
|
|
67
|
+
const UNSHIPPED_DEPS_GUARD = `
|
|
68
|
+
AND NOT EXISTS (
|
|
69
|
+
SELECT 1
|
|
70
|
+
FROM dependencies d
|
|
71
|
+
WHERE ((d.from_kind = 'task' AND d.from_id = t.id)
|
|
72
|
+
OR (d.from_kind = 'goal' AND d.from_id = t.goal_id)
|
|
73
|
+
OR (d.from_kind = 'criterion' AND d.from_id IN (
|
|
74
|
+
SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id)))
|
|
75
|
+
AND ((d.to_kind = 'task' AND EXISTS (SELECT 1 FROM tasks x WHERE x.id = d.to_id AND x.status <> 'shipped'))
|
|
76
|
+
OR (d.to_kind = 'criterion' AND EXISTS (SELECT 1 FROM done_when_criteria x WHERE x.id = d.to_id AND x.satisfied = false))
|
|
77
|
+
OR (d.to_kind = 'goal' AND EXISTS (SELECT 1 FROM goals x WHERE x.id = d.to_id AND x.status <> 'achieved')))
|
|
78
|
+
)`;
|
|
79
|
+
|
|
55
80
|
const VALID_MODES = ['active', 'check-in', 'autonomous'];
|
|
56
81
|
const DEFAULT_MAX_PARALLEL = 3;
|
|
57
82
|
|
|
@@ -90,7 +115,7 @@ async function optimizeSession({ builderId, mode, timeBudgetMin, maxParallel })
|
|
|
90
115
|
FROM tasks t
|
|
91
116
|
WHERE t.status = 'ready'
|
|
92
117
|
AND t.title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
|
|
93
|
-
${OPEN_BLOCKER_GUARD}
|
|
118
|
+
${OPEN_BLOCKER_GUARD}${UNSHIPPED_DEPS_GUARD}
|
|
94
119
|
AND NOT EXISTS (
|
|
95
120
|
SELECT 1 FROM claims c
|
|
96
121
|
JOIN tasks t2 ON t2.id = c.task_id
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.12'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|