@bongos/core 1.19.811 → 1.19.813
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 +108 -43
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +24 -0
- package/clients/bongos-client/index.cjs +24 -0
- package/clients/bongos-client/index.d.ts +39 -0
- package/clients/bongos-client/index.mjs +24 -0
- package/docs/adr/0296-a-role-speciality-is-data-over-three-existing-surfaces.md +71 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +867 -73
- package/docs/api-reference.md +19 -3
- package/docs/copy-inventory.md +48 -20
- package/docs/copy-registry.json +275 -23
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +24 -0
- package/modules/hall-ui/public/settings-specialities.js +241 -0
- package/modules/hall-ui/public/settings.css +56 -0
- package/modules/hall-ui/public/settings.html +65 -0
- package/modules/lifecycle/db-claim-reads.js +5 -1
- package/modules/lifecycle/db-executor.js +45 -0
- package/modules/lifecycle/db-goals.js +29 -7
- package/modules/lifecycle/db-tasks.js +11 -5
- package/modules/lifecycle/db-versions.js +17 -3
- package/modules/lifecycle/done-when.js +5 -2
- package/modules/lifecycle/goal-edits.js +5 -1
- package/modules/lifecycle/routes/done-when.js +150 -86
- package/modules/memory/learnings.js +21 -2
- package/modules/provisioning/starter-bundles.js +1 -0
- package/modules/specialities/CLAUDE.md +15 -0
- package/modules/specialities/db.js +234 -0
- package/modules/specialities/migrations/specialities_001_specialities.sql +143 -0
- package/modules/specialities/module.json +12 -0
- package/modules/specialities/routes/specialities.js +379 -0
- package/modules/specialities/specialities.js +198 -0
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/search-ingest-db.js +37 -0
- package/src/bongos/routes/me.js +17 -0
- package/src/module-api.js +1 -1
- package/tests/criterion_authoring_gate.mjs +22 -1
- package/tests/criterion_goal_attach.mjs +233 -8
- package/tests/module_loader.mjs +1 -1
- package/tests/own_scope_guard.mjs +10 -0
- package/tests/specialities.mjs +214 -0
- package/tests/speciality_corpus_scope.mjs +97 -0
- package/tests/speciality_db_shape.mjs +124 -0
- package/tests/speciality_injection.mjs +107 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// modules/hall-ui/public/settings-specialities.js — the "Your specialities" panel
|
|
2
|
+
// on /builders/settings (task 1003855; ADR 0296).
|
|
3
|
+
//
|
|
4
|
+
// WHAT A SPECIALITY IS, for anyone reading this file first: a craft says HOW you
|
|
5
|
+
// work (engineer / ideator / artist / ui); a speciality says what you work ON and
|
|
6
|
+
// how you, as a subject-matter expert, want to be engaged inside that craft. The
|
|
7
|
+
// contract you write here is injected into every session where you hold a claim
|
|
8
|
+
// in that craft.
|
|
9
|
+
//
|
|
10
|
+
// Its own file rather than more of settings.js, on the settings-interaction.js /
|
|
11
|
+
// settings-sessions.js precedent and for the same stated reason: settings.js sits
|
|
12
|
+
// against the 1500-line size ratchet. Self-contained — it shares nothing with the
|
|
13
|
+
// rest of the page but `window.OTB` and the generated client, both loaded before it.
|
|
14
|
+
//
|
|
15
|
+
// TRAINING IS TWO-FOLD, and only half of it is visible here. Documents you add are
|
|
16
|
+
// the half you drive; the other half accumulates on its own from learnings captured
|
|
17
|
+
// while you work in the speciality. The counts row says which is which, so "it
|
|
18
|
+
// knows 4 things" is never mistaken for "I typed 4 things".
|
|
19
|
+
|
|
20
|
+
(() => {
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const API = '/api/bongos';
|
|
24
|
+
const { escapeHtml, $ } = window.OTB;
|
|
25
|
+
const toast = (msg) => window.OTB.toast(msg);
|
|
26
|
+
|
|
27
|
+
const state = { rows: [], openId: null, busy: false };
|
|
28
|
+
|
|
29
|
+
const el = (id) => document.getElementById(id);
|
|
30
|
+
const statusLine = (msg) => { const s = el('specialities-status'); if (s) s.textContent = msg || ''; };
|
|
31
|
+
|
|
32
|
+
async function call(method, path, body) {
|
|
33
|
+
const res = await fetch(`${API}${path}`, {
|
|
34
|
+
method,
|
|
35
|
+
credentials: 'same-origin',
|
|
36
|
+
headers: body ? { 'Content-Type': 'application/json', Accept: 'application/json' } : { Accept: 'application/json' },
|
|
37
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
38
|
+
});
|
|
39
|
+
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return null; }
|
|
40
|
+
let data = null;
|
|
41
|
+
try { data = await res.json(); } catch { /* an empty body is legal on some paths */ }
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
// Surface the field-level reason when the server sent one — a bare
|
|
44
|
+
// "failed" on a form with five fields is useless.
|
|
45
|
+
const detail = data && data.details && Array.isArray(data.details.errors)
|
|
46
|
+
? data.details.errors.map((e) => `${e.field}: ${e.code}`).join(', ')
|
|
47
|
+
: (data && data.message) || `HTTP ${res.status}`;
|
|
48
|
+
throw new Error(detail);
|
|
49
|
+
}
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const VISIBILITY_COPY = {
|
|
54
|
+
private: 'Only you',
|
|
55
|
+
project: 'Anyone in the owning project',
|
|
56
|
+
public: 'Anyone on this instance',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
// THE INSTRUCTION THE ADOPTER IS AGREEING TO. A speciality someone else wrote
|
|
61
|
+
// puts their prose into YOUR sessions, which is the only place in this product
|
|
62
|
+
// where that happens — so it is shown in full before "Use this", never folded
|
|
63
|
+
// away behind a summary. Shown for your own too, because seeing what actually
|
|
64
|
+
// reaches a session is the point either way.
|
|
65
|
+
function contractHtml(s) {
|
|
66
|
+
if (!s.contract_md) return '';
|
|
67
|
+
const theirs = !s.mine;
|
|
68
|
+
return `
|
|
69
|
+
<details class="speciality-contract"${theirs ? ' open' : ''}>
|
|
70
|
+
<summary>${theirs ? 'What this adds to your sessions — written by someone else' : 'What this adds to your sessions'}</summary>
|
|
71
|
+
<pre class="speciality-contract__body">${escapeHtml(s.contract_md)}</pre>
|
|
72
|
+
${theirs ? '<p class="prow__hint">You did not write this. Read it before using the speciality — it reaches every session where you hold work in that craft, and it is passed to Claude as this author\'s stated preferences, not as instructions it must obey.</p>' : ''}
|
|
73
|
+
</details>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function cardHtml(s) {
|
|
77
|
+
const trained = Number(s.documentCount || 0);
|
|
78
|
+
const learned = Number(s.learningCount || 0);
|
|
79
|
+
const open = String(state.openId) === String(s.id);
|
|
80
|
+
return `
|
|
81
|
+
<li class="prow speciality-card" data-id="${escapeHtml(String(s.id))}">
|
|
82
|
+
<div class="prow__main">
|
|
83
|
+
<div class="prow__label">
|
|
84
|
+
<strong>${escapeHtml(s.name)}</strong>
|
|
85
|
+
<span class="speciality-craft">${escapeHtml(s.discipline)}</span>
|
|
86
|
+
${s.active ? '<span class="speciality-badge speciality-badge--on">active</span>' : ''}
|
|
87
|
+
</div>
|
|
88
|
+
<p class="prow__hint">${escapeHtml(s.summary || 'No summary yet.')}</p>
|
|
89
|
+
${contractHtml(s)}
|
|
90
|
+
<p class="prow__hint speciality-counts">
|
|
91
|
+
Trained on <strong>${trained}</strong> document${trained === 1 ? '' : 's'} you added
|
|
92
|
+
· learned <strong>${learned}</strong> thing${learned === 1 ? '' : 's'} from your sessions
|
|
93
|
+
· shared with: ${escapeHtml(VISIBILITY_COPY[s.visibility] || s.visibility || 'only you')}
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="prow__actions">
|
|
97
|
+
${s.mine ? `<button type="button" class="btn btn--quiet" data-act="train" data-id="${escapeHtml(String(s.id))}">${open ? 'Close' : 'Train'}</button>` : ''}
|
|
98
|
+
${s.active
|
|
99
|
+
? `<button type="button" class="btn btn--quiet" data-act="abandon" data-id="${escapeHtml(String(s.id))}">Stop using</button>`
|
|
100
|
+
: `<button type="button" class="btn" data-act="adopt" data-id="${escapeHtml(String(s.id))}" ${s.adoptable ? '' : 'disabled'}>Use this</button>`}
|
|
101
|
+
</div>
|
|
102
|
+
${open ? trainHtml(s) : ''}
|
|
103
|
+
</li>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function trainHtml(s) {
|
|
107
|
+
return `
|
|
108
|
+
<form class="speciality-train" data-id="${escapeHtml(String(s.id))}">
|
|
109
|
+
<p class="prow__hint">
|
|
110
|
+
Add something you wrote — notes, a standard, a worked example. It is indexed
|
|
111
|
+
for recall, not pasted into every session, so length costs you nothing.
|
|
112
|
+
</p>
|
|
113
|
+
<label class="speciality-field">
|
|
114
|
+
<span>Title</span>
|
|
115
|
+
<input type="text" name="title" maxlength="200" required placeholder="How we price a swap" />
|
|
116
|
+
</label>
|
|
117
|
+
<label class="speciality-field">
|
|
118
|
+
<span>The document</span>
|
|
119
|
+
<textarea name="body_md" rows="6" required placeholder="Paste or write it here."></textarea>
|
|
120
|
+
</label>
|
|
121
|
+
<div class="speciality-actions">
|
|
122
|
+
<button type="submit" class="btn">Add to what it knows</button>
|
|
123
|
+
</div>
|
|
124
|
+
</form>`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function render() {
|
|
128
|
+
const host = el('specialities-rows');
|
|
129
|
+
if (!host) return;
|
|
130
|
+
if (!state.rows.length) {
|
|
131
|
+
host.innerHTML = `<li class="prow"><div class="prow__main">
|
|
132
|
+
<p class="prow__hint">No specialities yet. Create one below — it takes a name and a craft, and you can train it afterwards.</p>
|
|
133
|
+
</div></li>`;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
host.innerHTML = state.rows.map(cardHtml).join('');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function load() {
|
|
140
|
+
try {
|
|
141
|
+
const [cat, mine] = await Promise.all([call('GET', '/specialities'), call('GET', '/me/specialities')]);
|
|
142
|
+
if (!cat) return;
|
|
143
|
+
const activeIds = new Set(((mine && mine.adoptions) || []).filter((a) => a.active).map((a) => String(a.id)));
|
|
144
|
+
state.rows = ((cat && cat.specialities) || []).map((s) => ({ ...s, active: activeIds.has(String(s.id)) }));
|
|
145
|
+
render();
|
|
146
|
+
statusLine('');
|
|
147
|
+
} catch (err) {
|
|
148
|
+
statusLine(`Could not load specialities — ${err.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function act(action, id) {
|
|
153
|
+
if (state.busy) return;
|
|
154
|
+
state.busy = true;
|
|
155
|
+
try {
|
|
156
|
+
if (action === 'adopt') { await call('POST', `/specialities/${id}/adopt`); toast('Using this speciality.'); }
|
|
157
|
+
if (action === 'abandon') { await call('POST', `/specialities/${id}/abandon`); toast('Stopped using it.'); }
|
|
158
|
+
await load();
|
|
159
|
+
} catch (err) {
|
|
160
|
+
statusLine(err.message);
|
|
161
|
+
} finally {
|
|
162
|
+
state.busy = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function wire() {
|
|
167
|
+
const host = el('specialities-rows');
|
|
168
|
+
if (host) {
|
|
169
|
+
host.addEventListener('click', (e) => {
|
|
170
|
+
const btn = e.target.closest('button[data-act]');
|
|
171
|
+
if (!btn) return;
|
|
172
|
+
const { act: action, id } = btn.dataset;
|
|
173
|
+
if (action === 'train') { state.openId = String(state.openId) === String(id) ? null : id; render(); return; }
|
|
174
|
+
act(action, id);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
host.addEventListener('submit', async (e) => {
|
|
178
|
+
const form = e.target.closest('form.speciality-train');
|
|
179
|
+
if (!form) return;
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
const title = form.elements.title.value.trim();
|
|
182
|
+
const body = form.elements.body_md.value.trim();
|
|
183
|
+
// Validate before calling: an empty field should say so inline rather
|
|
184
|
+
// than round-trip to a 400.
|
|
185
|
+
if (!title || !body) { statusLine('A document needs both a title and some content.'); return; }
|
|
186
|
+
try {
|
|
187
|
+
const out = await call('POST', `/specialities/${form.dataset.id}/documents`, { title, body_md: body });
|
|
188
|
+
toast(out && out.skipped ? 'It already knew that one.' : 'Added.');
|
|
189
|
+
state.openId = null;
|
|
190
|
+
await load();
|
|
191
|
+
} catch (err) { statusLine(err.message); }
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const create = el('speciality-create');
|
|
196
|
+
if (create) {
|
|
197
|
+
create.addEventListener('submit', async (e) => {
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
const name = create.elements.name.value.trim();
|
|
200
|
+
const discipline = create.elements.discipline.value;
|
|
201
|
+
if (!name) { statusLine('Give it a name.'); return; }
|
|
202
|
+
if (!discipline) { statusLine('Pick which craft it sits under.'); return; }
|
|
203
|
+
try {
|
|
204
|
+
await call('POST', '/specialities', {
|
|
205
|
+
name,
|
|
206
|
+
discipline,
|
|
207
|
+
summary: create.elements.summary.value.trim(),
|
|
208
|
+
contract_md: create.elements.contract_md.value.trim(),
|
|
209
|
+
visibility: create.elements.visibility.value,
|
|
210
|
+
sellable: create.elements.sellable.checked,
|
|
211
|
+
});
|
|
212
|
+
create.reset();
|
|
213
|
+
toast('Speciality created.');
|
|
214
|
+
await load();
|
|
215
|
+
} catch (err) { statusLine(err.message); }
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// The server refuses private+sellable (there is nobody it could be sold
|
|
219
|
+
// to). Mirror that here so the checkbox cannot be left in a state the save
|
|
220
|
+
// will reject.
|
|
221
|
+
const vis = create.elements.visibility;
|
|
222
|
+
const sell = create.elements.sellable;
|
|
223
|
+
const sync = () => {
|
|
224
|
+
const isPrivate = vis.value === 'private';
|
|
225
|
+
sell.disabled = isPrivate;
|
|
226
|
+
if (isPrivate) sell.checked = false;
|
|
227
|
+
};
|
|
228
|
+
vis.addEventListener('change', sync);
|
|
229
|
+
sync();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function boot() {
|
|
234
|
+
if (!el('specialities-rows')) return; // panel not on this page
|
|
235
|
+
wire();
|
|
236
|
+
load();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
|
|
240
|
+
else boot();
|
|
241
|
+
})();
|
|
@@ -161,3 +161,59 @@ select.voices__select { min-width: 140px; }
|
|
|
161
161
|
/* the box panel is the shell's (.setup__box, box-panel.js); inside a settings
|
|
162
162
|
panel it sits on the glass rather than a second card */
|
|
163
163
|
.settings-groups .setup__box { margin: var(--sp-sm) 0 0; background: var(--glass); border-color: var(--glass-edge); border-radius: var(--radius-sm); }
|
|
164
|
+
|
|
165
|
+
/* Specialities panel (task 1003855, ADR 0296). A speciality is a named bundle of
|
|
166
|
+
knowledge and terms under a craft; this styles the list, the train form and the
|
|
167
|
+
create form. Tokens only — no literal colours, so light/dark follow the page. */
|
|
168
|
+
.speciality-craft {
|
|
169
|
+
font-size: 0.78rem;
|
|
170
|
+
text-transform: lowercase;
|
|
171
|
+
opacity: 0.72;
|
|
172
|
+
margin-left: 0.4rem;
|
|
173
|
+
}
|
|
174
|
+
.speciality-badge {
|
|
175
|
+
font-size: 0.7rem;
|
|
176
|
+
padding: 0.05rem 0.4rem;
|
|
177
|
+
border-radius: 0.5rem;
|
|
178
|
+
margin-left: 0.4rem;
|
|
179
|
+
border: 1px solid currentColor;
|
|
180
|
+
}
|
|
181
|
+
.speciality-counts { opacity: 0.82; }
|
|
182
|
+
.speciality-card { flex-wrap: wrap; }
|
|
183
|
+
.speciality-train,
|
|
184
|
+
.speciality-create {
|
|
185
|
+
flex-basis: 100%;
|
|
186
|
+
margin-top: 0.75rem;
|
|
187
|
+
padding-top: 0.75rem;
|
|
188
|
+
border-top: 1px solid var(--rule, rgba(128, 128, 128, 0.25));
|
|
189
|
+
display: flex;
|
|
190
|
+
flex-direction: column;
|
|
191
|
+
gap: 0.6rem;
|
|
192
|
+
}
|
|
193
|
+
.speciality-create__h { margin: 0; font-size: 0.95rem; }
|
|
194
|
+
.speciality-field { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
195
|
+
.speciality-field > span { font-size: 0.85rem; }
|
|
196
|
+
.speciality-field small { display: block; opacity: 0.7; font-weight: normal; }
|
|
197
|
+
.speciality-field input[type='text'],
|
|
198
|
+
.speciality-field select,
|
|
199
|
+
.speciality-field textarea {
|
|
200
|
+
width: 100%;
|
|
201
|
+
box-sizing: border-box;
|
|
202
|
+
}
|
|
203
|
+
/* The checkbox row reads as one sentence, so it runs inline rather than stacked. */
|
|
204
|
+
.speciality-field--check { flex-direction: row; align-items: flex-start; gap: 0.5rem; }
|
|
205
|
+
.speciality-field--check input { margin-top: 0.2rem; }
|
|
206
|
+
.speciality-actions { display: flex; gap: 0.5rem; }
|
|
207
|
+
/* The adopted contract, shown in full before "Use this" — another builder's prose
|
|
208
|
+
reaching your sessions is the one thing here that must never be folded away. */
|
|
209
|
+
.speciality-contract { flex-basis: 100%; margin-top: 0.5rem; font-size: 0.85rem; }
|
|
210
|
+
.speciality-contract > summary { cursor: pointer; opacity: 0.85; }
|
|
211
|
+
.speciality-contract__body {
|
|
212
|
+
white-space: pre-wrap;
|
|
213
|
+
margin: 0.4rem 0 0;
|
|
214
|
+
padding: 0.5rem 0.65rem;
|
|
215
|
+
border-left: 2px solid var(--rule, rgba(128, 128, 128, 0.35));
|
|
216
|
+
border-radius: 0;
|
|
217
|
+
font-family: inherit;
|
|
218
|
+
opacity: 0.92;
|
|
219
|
+
}
|
|
@@ -269,6 +269,70 @@
|
|
|
269
269
|
<p class="settings-status" id="interaction-status" role="status" aria-live="polite"></p>
|
|
270
270
|
</section>
|
|
271
271
|
|
|
272
|
+
<!-- SPECIALITIES — a named bundle of knowledge + terms UNDER a craft
|
|
273
|
+
(task 1003855, ADR 0296). GET/POST /api/bongos/specialities,
|
|
274
|
+
POST /specialities/:id/{adopt,abandon,documents}. No rank gate:
|
|
275
|
+
a speciality is how an expert's knowledge enters the platform,
|
|
276
|
+
and gating that by rank would mean only trusted builders could
|
|
277
|
+
be experts. -->
|
|
278
|
+
<section class="scroll" id="specialities-scroll" aria-labelledby="specialities-h" data-group="preferences" hidden>
|
|
279
|
+
<h2 id="specialities-h" class="scroll__h">Your specialities</h2>
|
|
280
|
+
<p class="scroll__lede">
|
|
281
|
+
A craft says how you work. A speciality says what you work on — and how you,
|
|
282
|
+
as someone who knows the subject, want to be engaged inside it. Whichever one
|
|
283
|
+
you are using reaches every session where you hold a claim in that craft.
|
|
284
|
+
Train it by adding things you have written; it also picks things up on its own
|
|
285
|
+
as you work.
|
|
286
|
+
</p>
|
|
287
|
+
<ul class="prows" id="specialities-rows"></ul>
|
|
288
|
+
|
|
289
|
+
<form id="speciality-create" class="speciality-create">
|
|
290
|
+
<h3 class="speciality-create__h">Create one</h3>
|
|
291
|
+
<label class="speciality-field">
|
|
292
|
+
<span>Name</span>
|
|
293
|
+
<input type="text" name="name" maxlength="80" required placeholder="Economics" />
|
|
294
|
+
</label>
|
|
295
|
+
<label class="speciality-field">
|
|
296
|
+
<span>Craft it sits under</span>
|
|
297
|
+
<select name="discipline" required>
|
|
298
|
+
<option value="">Pick one…</option>
|
|
299
|
+
<option value="engineer">Engineer</option>
|
|
300
|
+
<option value="ideator">Ideator</option>
|
|
301
|
+
<option value="artist">Artist</option>
|
|
302
|
+
<option value="ui">UI design</option>
|
|
303
|
+
</select>
|
|
304
|
+
</label>
|
|
305
|
+
<label class="speciality-field">
|
|
306
|
+
<span>Summary <small>(optional)</small></span>
|
|
307
|
+
<input type="text" name="summary" maxlength="500" placeholder="Markets, incentives, and how to price them." />
|
|
308
|
+
</label>
|
|
309
|
+
<label class="speciality-field">
|
|
310
|
+
<span>How Claude should engage you in it <small>(optional)</small></span>
|
|
311
|
+
<textarea name="contract_md" rows="4" maxlength="4000" placeholder="Argue from mechanism, not vibes. Always name the assumption a number rests on."></textarea>
|
|
312
|
+
</label>
|
|
313
|
+
<label class="speciality-field">
|
|
314
|
+
<span>Who can use it</span>
|
|
315
|
+
<select name="visibility">
|
|
316
|
+
<option value="private">Only me</option>
|
|
317
|
+
<option value="project">Anyone in the owning project</option>
|
|
318
|
+
<option value="public">Anyone on this instance</option>
|
|
319
|
+
</select>
|
|
320
|
+
</label>
|
|
321
|
+
<label class="speciality-field speciality-field--check">
|
|
322
|
+
<input type="checkbox" name="sellable" />
|
|
323
|
+
<span>
|
|
324
|
+
May be offered for sale later
|
|
325
|
+
<small>Records your decision only — there is no marketplace yet, and nothing is listed anywhere.</small>
|
|
326
|
+
</span>
|
|
327
|
+
</label>
|
|
328
|
+
<div class="speciality-actions">
|
|
329
|
+
<button type="submit" class="btn">Create speciality</button>
|
|
330
|
+
</div>
|
|
331
|
+
</form>
|
|
332
|
+
|
|
333
|
+
<p class="settings-status" id="specialities-status" role="status" aria-live="polite"></p>
|
|
334
|
+
</section>
|
|
335
|
+
|
|
272
336
|
<!-- WANDERING — how readily the agent opens off-task threads (task 1268).
|
|
273
337
|
GET/PATCH /api/bongos/me/wandering. Rank sets the default + ceiling; the
|
|
274
338
|
server clamps, so options above the rank cap render disabled. -->
|
|
@@ -398,6 +462,7 @@
|
|
|
398
462
|
the same precedent and for the same reason — settings.js is against the
|
|
399
463
|
1500-line ratchet, and this panel shares nothing with the rest of the page. -->
|
|
400
464
|
<script src="/builders/settings-interaction.js?v=2026-09-15-interaction"></script>
|
|
465
|
+
<script src="/builders/settings-specialities.js?v=2026-09-17-specialities"></script>
|
|
401
466
|
<!-- task 1002518: the card-richness ladder. Own file on the same precedent —
|
|
402
467
|
the panel grew from one switch to a rung picker with a derived default, and
|
|
403
468
|
settings.js is against the 1500-line ratchet. -->
|
|
@@ -21,7 +21,11 @@ async function getActiveClaimsForBuilder(builderId) {
|
|
|
21
21
|
`SELECT c.id AS claim_id, c.task_id, c.worktree_name, c.claimed_at,
|
|
22
22
|
c.head_sha, c.reserved_migration_number,
|
|
23
23
|
c.release_requested_by, c.release_requested_at, c.release_requested_note,
|
|
24
|
-
t.title, t.touches, t.priority, t.credits_reward, t.version_id, t.module_key
|
|
24
|
+
t.title, t.touches, t.priority, t.credits_reward, t.version_id, t.module_key,
|
|
25
|
+
-- task 1003855: the craft of the claimed work. A speciality contract is
|
|
26
|
+
-- craft-scoped, so the Conductor needs to know which craft is in play
|
|
27
|
+
-- before it can pick the right one to inject.
|
|
28
|
+
t.discipline
|
|
25
29
|
FROM claims c JOIN tasks t ON t.id = c.task_id
|
|
26
30
|
WHERE c.builder_id = $1 AND c.released_at IS NULL
|
|
27
31
|
ORDER BY c.claimed_at`,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/db-executor.js — which thing runs the query (task 1003840).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS IS ONE FUNCTION AND NOT SEVEN COPIES. Every read in this module family
|
|
6
|
+
// already carried the same hand-written line:
|
|
7
|
+
//
|
|
8
|
+
// const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
9
|
+
//
|
|
10
|
+
// Threading a transaction client through the criterion surface meant editing that
|
|
11
|
+
// line in seven files. A rule about WHICH connection a write lands on is exactly
|
|
12
|
+
// the rule you do not want seven copies of: get one wrong and a write silently
|
|
13
|
+
// leaves the transaction it was supposed to be inside, which is invisible in
|
|
14
|
+
// review and invisible at runtime until a race loses.
|
|
15
|
+
//
|
|
16
|
+
// THE ORDER MATTERS, and it is not the same for the two seams:
|
|
17
|
+
//
|
|
18
|
+
// 1. `deps.client` — a real transaction client, honoured ALWAYS. It is
|
|
19
|
+
// production behaviour, not a test affordance: when a caller has opened a
|
|
20
|
+
// transaction, every statement in that unit of work must run on that client
|
|
21
|
+
// or it is not in the transaction at all.
|
|
22
|
+
// 2. `deps.pool` — the #538 test seam, honoured ONLY under NODE_ENV==='test',
|
|
23
|
+
// exactly as before. Unchanged so a stray deps.pool in production code still
|
|
24
|
+
// cannot redirect a write.
|
|
25
|
+
// 3. the module's own pool — the default, unchanged.
|
|
26
|
+
//
|
|
27
|
+
// A caller that passes both gets the client, because the transaction is the
|
|
28
|
+
// stronger claim.
|
|
29
|
+
|
|
30
|
+
function executorFor(deps, defaultPool) {
|
|
31
|
+
if (deps && deps.client) return deps.client;
|
|
32
|
+
if (deps && deps.pool && process.env.NODE_ENV === 'test') return deps.pool;
|
|
33
|
+
return defaultPool;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// True when the caller is inside a transaction, which is the only place a row
|
|
37
|
+
// lock means anything: `SELECT ... FOR UPDATE` on the pool takes a lock and
|
|
38
|
+
// releases it on the next statement, so asking for one outside a transaction is
|
|
39
|
+
// a false sense of safety rather than a cheap extra. Callers use this to refuse
|
|
40
|
+
// rather than to silently downgrade.
|
|
41
|
+
function inTransaction(deps) {
|
|
42
|
+
return Boolean(deps && deps.client);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { executorFor, inTransaction };
|
|
@@ -17,6 +17,7 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
|
|
|
17
17
|
const { nonTerminalSql, isTerminalStatus } = require('./task-status');
|
|
18
18
|
const { maintenanceGoalTitle } = require('./goal-advisory.js');
|
|
19
19
|
const { SMOKE_NOT_LIKE } = require('./db-shared.js');
|
|
20
|
+
const { executorFor, inTransaction } = require('./db-executor.js');
|
|
20
21
|
// BV1.R16 (task 1003603) — auto-close, shared with the done-when cascade so the
|
|
21
22
|
// two achievement writers cannot drift apart (ADR 0263 §4).
|
|
22
23
|
const { autoCloseVersionsForGoals } = require('./version-autoclose.js');
|
|
@@ -325,10 +326,22 @@ async function getGoalAdmission(goalId, deps = {}) {
|
|
|
325
326
|
}
|
|
326
327
|
|
|
327
328
|
|
|
329
|
+
// deps.client runs the read inside a caller's transaction; deps.forUpdate adds the
|
|
330
|
+
// row lock (task 1003840). The lock is what actually closes the criterion routes'
|
|
331
|
+
// check-then-write window: without it, re-reading inside the transaction still sees
|
|
332
|
+
// a row another transaction is free to change before the write lands.
|
|
333
|
+
//
|
|
334
|
+
// forUpdate REFUSES outside a transaction rather than quietly dropping to a plain
|
|
335
|
+
// read. FOR UPDATE on a pooled connection takes a lock and releases it at the end
|
|
336
|
+
// of that implicit statement, so the caller would hold nothing while believing it
|
|
337
|
+
// held something — the precise false sense of safety this task exists to remove.
|
|
328
338
|
async function getGoal(id, deps = {}) {
|
|
329
|
-
const activePool = (deps
|
|
339
|
+
const activePool = executorFor(deps, pool);
|
|
340
|
+
if (deps.forUpdate && !inTransaction(deps)) {
|
|
341
|
+
throw new Error('getGoal({ forUpdate }) requires deps.client — a row lock outside a transaction holds nothing');
|
|
342
|
+
}
|
|
330
343
|
const { rows } = await activePool.query(
|
|
331
|
-
`SELECT ${GOAL_COLS} FROM goals WHERE id = $1`,
|
|
344
|
+
`SELECT ${GOAL_COLS} FROM goals WHERE id = $1${deps.forUpdate ? ' FOR UPDATE' : ''}`,
|
|
332
345
|
[id]
|
|
333
346
|
);
|
|
334
347
|
return rows[0] ?? null;
|
|
@@ -444,8 +457,15 @@ async function reorderGoals({ versionId, orderedIds }, deps = {}) {
|
|
|
444
457
|
// the auto-achieve + archive/reopen on top). Bumps updated_at. Returns null if
|
|
445
458
|
// the goal id is unknown.
|
|
446
459
|
|
|
460
|
+
// THE WRITE achieveGoalIfComplete DELEGATES TO, so it must honour deps.client or
|
|
461
|
+
// the auto-achieve leaves the caller's transaction (found in review of task
|
|
462
|
+
// 1003840's first cut, where it was missed). Against real Postgres that is not
|
|
463
|
+
// merely untransacted, it SELF-DEADLOCKS: the criterion route holds this goal row
|
|
464
|
+
// FOR UPDATE on its transaction client, and this UPDATE on a pool connection
|
|
465
|
+
// blocks on that lock — while the transaction is awaiting this UPDATE before it
|
|
466
|
+
// can COMMIT and release it. Neither side can move.
|
|
447
467
|
async function setGoalStatus({ goalId, status }, deps = {}) {
|
|
448
|
-
const activePool = (deps
|
|
468
|
+
const activePool = executorFor(deps, pool);
|
|
449
469
|
const { rows } = await activePool.query(
|
|
450
470
|
`UPDATE goals
|
|
451
471
|
SET status = $2, updated_at = now()
|
|
@@ -467,8 +487,10 @@ async function setGoalStatus({ goalId, status }, deps = {}) {
|
|
|
467
487
|
// siblings, so the flip logic is unit-testable with a fake client.
|
|
468
488
|
|
|
469
489
|
async function achieveGoalIfComplete({ goalId }, deps = {}) {
|
|
470
|
-
const activePool = (deps
|
|
471
|
-
|
|
490
|
+
const activePool = executorFor(deps, pool);
|
|
491
|
+
// The nested getGoal must NOT inherit forUpdate — this is a re-derive, not the
|
|
492
|
+
// authority read, and locking here would widen the transaction for no gain.
|
|
493
|
+
const goal = await getGoal(goalId, { ...deps, forUpdate: false });
|
|
472
494
|
// Only an OPEN goal can auto-achieve. An already achieved/archived goal is left
|
|
473
495
|
// exactly as-is — re-confirming a criterion must not churn its status.
|
|
474
496
|
if (!goal || goal.status !== 'open') return { achieved: false, goal: goal ?? null };
|
|
@@ -512,7 +534,7 @@ async function achieveGoalIfComplete({ goalId }, deps = {}) {
|
|
|
512
534
|
//
|
|
513
535
|
// Runs on the caller's exec so it sees the flip above, which in the ship path is
|
|
514
536
|
// still uncommitted. Never throws: autoCloseVersionsForGoals catches per version.
|
|
515
|
-
const exec =
|
|
537
|
+
const exec = executorFor(deps, pool);
|
|
516
538
|
const versionsClosed = await autoCloseVersionsForGoals(exec, [updated]);
|
|
517
539
|
return { achieved: true, goal: updated, versionsClosed };
|
|
518
540
|
}
|
|
@@ -829,7 +851,7 @@ async function isGoalMember({ goalId, builderId }, deps = {}) {
|
|
|
829
851
|
// isGoalMember boolean.
|
|
830
852
|
|
|
831
853
|
async function getGoalMember({ goalId, builderId }, deps = {}) {
|
|
832
|
-
const activePool = (deps
|
|
854
|
+
const activePool = executorFor(deps, pool);
|
|
833
855
|
const { rows } = await activePool.query(
|
|
834
856
|
`SELECT goal_id, builder_id, membership_kind, joined_at
|
|
835
857
|
FROM goal_members
|
|
@@ -17,6 +17,7 @@ const { generalGoalTitle } = require('./goal-advisory.js');
|
|
|
17
17
|
const { matchProtected } = api.permissionPathCheck;
|
|
18
18
|
const { decorateGoalMembership, capNewcomerTasks } = require('./claim-feed.js');
|
|
19
19
|
const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
|
|
20
|
+
const { executorFor } = require('./db-executor.js');
|
|
20
21
|
|
|
21
22
|
// -------------------------------------------------------------------------
|
|
22
23
|
// Tasks
|
|
@@ -334,16 +335,21 @@ async function updateTaskColumn(id, column, value) {
|
|
|
334
335
|
// planning session uses. sort_order defaults to max(existing)+1 (mirrors the
|
|
335
336
|
// criterion-proposal ratify path). Throws 23505 on a duplicate (version_id,
|
|
336
337
|
// criterion_id) — the route maps that to 409.
|
|
337
|
-
|
|
338
|
+
// deps.client threads the caller's transaction (task 1003840). BOTH statements
|
|
339
|
+
// below must run on it: the MAX(sort_order) read and the INSERT that depends on
|
|
340
|
+
// it are one decision, and splitting them across connections reopens a narrower
|
|
341
|
+
// version of the same race — two creates reading the same next order.
|
|
342
|
+
async function createCriterion({ versionId, criterionId, criterionMd, goalId = null, sortOrder = null }, deps = {}) {
|
|
343
|
+
const executor = executorFor(deps, pool);
|
|
338
344
|
let order = sortOrder;
|
|
339
345
|
if (order == null) {
|
|
340
|
-
const { rows: maxRows } = await
|
|
346
|
+
const { rows: maxRows } = await executor.query(
|
|
341
347
|
`SELECT COALESCE(MAX(sort_order), 0) + 1 AS next FROM done_when_criteria WHERE version_id = $1`,
|
|
342
348
|
[versionId]
|
|
343
349
|
);
|
|
344
350
|
order = maxRows[0].next;
|
|
345
351
|
}
|
|
346
|
-
const { rows } = await
|
|
352
|
+
const { rows } = await executor.query(
|
|
347
353
|
`INSERT INTO done_when_criteria (version_id, criterion_id, criterion_md, goal_id, sort_order)
|
|
348
354
|
VALUES ($1, $2, $3, $4, $5)
|
|
349
355
|
RETURNING id, version_id, criterion_id, criterion_md, goal_id, sort_order, satisfied`,
|
|
@@ -364,8 +370,8 @@ async function createCriterion({ versionId, criterionId, criterionMd, goalId = n
|
|
|
364
370
|
// question this raises — that the goal exists and sits on the criterion's own
|
|
365
371
|
// version, who may write it, and whether the move completes the goal; this is the
|
|
366
372
|
// UPDATE and nothing else, exactly as updateTaskGoal below is for tasks.
|
|
367
|
-
async function updateCriterionGoal(id, goalId) {
|
|
368
|
-
const { rows } = await pool.query(
|
|
373
|
+
async function updateCriterionGoal(id, goalId, deps = {}) {
|
|
374
|
+
const { rows } = await executorFor(deps, pool).query(
|
|
369
375
|
`UPDATE done_when_criteria SET goal_id = $1 WHERE id = $2
|
|
370
376
|
RETURNING id, version_id, criterion_id, criterion_md, goal_id, sort_order, satisfied`,
|
|
371
377
|
[goalId, id]
|
|
@@ -15,6 +15,7 @@ const { pool, withTx } = api;
|
|
|
15
15
|
const doneWhen = require('./done-when.js');
|
|
16
16
|
const { nonTerminalSql } = require('./task-status');
|
|
17
17
|
const { SMOKE_NOT_LIKE } = require('./db-shared.js');
|
|
18
|
+
const { executorFor, inTransaction } = require('./db-executor.js');
|
|
18
19
|
// db-goals.js does NOT require this file, so this direction adds no require cycle
|
|
19
20
|
// (fitness pins the count). createVersion needs it to seed a version's FIRST goal
|
|
20
21
|
// in the same transaction — task 1003701.
|
|
@@ -30,10 +31,23 @@ async function listVersions() {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
// deps.client/deps.pool, so the criterion routes can read the version inside the
|
|
35
|
+
// same transaction that authorizes and writes (task 1003840). A version's status
|
|
36
|
+
// is one of the facts the decision rests on — reading it on a different connection
|
|
37
|
+
// is reading it at a different instant.
|
|
38
|
+
async function getVersion(id, deps = {}) {
|
|
39
|
+
// deps.forUpdate locks the version row too (task 1003840, round 2). The goal
|
|
40
|
+
// lock alone left one of the three named facts unguarded: closeVersion takes
|
|
41
|
+
// FOR UPDATE on this row, but under READ COMMITTED a PLAIN read is never
|
|
42
|
+
// blocked by another session's lock — so a freeze could still commit between
|
|
43
|
+
// this read and this transaction's COMMIT, and the criterion would land on a
|
|
44
|
+
// version that had closed. Locking makes the two serialize.
|
|
45
|
+
if (deps.forUpdate && !inTransaction(deps)) {
|
|
46
|
+
throw new Error('getVersion({ forUpdate }) requires deps.client — a row lock outside a transaction holds nothing');
|
|
47
|
+
}
|
|
48
|
+
const { rows } = await executorFor(deps, pool).query(
|
|
35
49
|
`SELECT id, name, track, status, scope_doc_path, started_at, shipped_at, done_when
|
|
36
|
-
FROM versions WHERE id = $1`,
|
|
50
|
+
FROM versions WHERE id = $1${deps.forUpdate ? ' FOR UPDATE' : ''}`,
|
|
37
51
|
[id]
|
|
38
52
|
);
|
|
39
53
|
return rows[0] ?? null;
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
const { pool } = require('../../src/module-api');
|
|
27
27
|
const { nonTerminalSql } = require('./task-status');
|
|
28
28
|
const { SMOKE_NOT_LIKE } = require('./db-shared.js');
|
|
29
|
+
const { executorFor } = require('./db-executor.js');
|
|
29
30
|
// BV1.R16 (task 1003603) — auto-close. Dependency-free module, so requiring it
|
|
30
31
|
// here creates no cycle (db-versions.js requires THIS file, which is why the
|
|
31
32
|
// rule could not live beside closeVersion).
|
|
@@ -101,10 +102,12 @@ async function criteriaGroupedByGoal(versionId) {
|
|
|
101
102
|
// Fetch a single criterion by primary key. Used by the routes to validate
|
|
102
103
|
// existence before writing (so a missing id returns 404 instead of a silent
|
|
103
104
|
// no-op UPDATE).
|
|
104
|
-
|
|
105
|
+
// deps.client so the PATCH route can read the criterion inside its transaction
|
|
106
|
+
// (task 1003840) — its goal_id is the fact the authority decision rests on.
|
|
107
|
+
async function getCriterion(criterionId, deps = {}) {
|
|
105
108
|
const id = Number(criterionId);
|
|
106
109
|
if (!Number.isFinite(id)) return null;
|
|
107
|
-
const { rows } = await pool.query(
|
|
110
|
+
const { rows } = await executorFor(deps, pool).query(
|
|
108
111
|
`SELECT id, version_id, criterion_id, criterion_md, satisfied,
|
|
109
112
|
satisfied_by_task_id, checked_at, sort_order, goal_id
|
|
110
113
|
FROM done_when_criteria
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// correction is a cohesive slice — the goal-authz.js / claim-feed.js precedent.
|
|
24
24
|
|
|
25
25
|
const api = require('../../src/module-api');
|
|
26
|
+
const { executorFor } = require('./db-executor.js');
|
|
26
27
|
const { pool } = api;
|
|
27
28
|
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
@@ -119,7 +120,10 @@ async function updateGoalProse({ goalId, patch = {} }, deps = {}) {
|
|
|
119
120
|
// (criterionProseRefusal) so the route can answer 404 vs 409 distinctly; this
|
|
120
121
|
// only writes. Returns { criterion, deltas } or null when it vanished mid-flight.
|
|
121
122
|
async function updateCriterionProse({ criterionId, criterionMd }, deps = {}) {
|
|
122
|
-
|
|
123
|
+
// Read-then-write in its own right: the before-value it captures for the audit
|
|
124
|
+
// delta must come from the same transaction as the UPDATE, or the recorded
|
|
125
|
+
// "before" can be a value that was already gone (task 1003840).
|
|
126
|
+
const activePool = executorFor(deps, pool);
|
|
123
127
|
const { rows: before } = await activePool.query(
|
|
124
128
|
`SELECT id, criterion_md, satisfied FROM done_when_criteria WHERE id = $1`,
|
|
125
129
|
[criterionId]
|