@bongos/core 1.19.359 → 1.19.361
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 +72 -42
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +170 -3
- package/docs/api-reference.md +9 -1
- package/docs/file-map.md +2 -0
- package/modules/autonomy/routes/autonomy.js +19 -0
- package/modules/dev-box/app/src/config.js +13 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
- package/modules/economy/achievements.js +21 -0
- package/modules/economy/routes/cost.js +16 -0
- package/modules/economy/routes/leaderboard.js +12 -0
- package/modules/ideas/routes/blockers.js +24 -3
- package/modules/ideas/routes/inbox.js +34 -3
- package/modules/lifecycle/closeness-weights.json +64 -0
- package/modules/lifecycle/closeness.js +363 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/claims.js +22 -0
- package/modules/lifecycle/routes/closeness.js +118 -0
- package/modules/lifecycle/routes/tasks.js +22 -0
- package/modules/lifecycle/routes/versions.js +18 -0
- package/modules/memory/routes/learnings.js +21 -0
- package/modules/platform-identity/platform-identity.js +28 -0
- package/modules/platform-identity/routes/sso.js +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-md-template.js +1 -0
- package/scripts/gds/doc-comment-coverage.js +139 -0
- package/scripts/gds/fitness.js +5 -0
- package/scripts/gds/me.js +10 -1
- package/src/bongos/routes/auth.js +28 -0
- package/src/bongos/routes/builders.js +24 -0
- package/src/bongos/routes/healthz.js +10 -0
- package/src/bongos/routes/me.js +21 -0
- package/src/bongos/routes/optimizer.js +12 -0
- package/src/bongos/routes/public.js +19 -1
- package/src/module-api.js +5 -1
- package/tests/closeness.mjs +324 -0
- package/tests/doc_comment_coverage.mjs +87 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// modules/lifecycle/closeness.js — how close two builders are, and WHY
|
|
2
|
+
// (task 1003174 / C04, goal 1000075; criterion collab-closeness-consented,
|
|
3
|
+
// ADR 0187 §2).
|
|
4
|
+
//
|
|
5
|
+
// THE DECISION. Closeness is a WEIGHTED BLEND — co-claims, co-build sessions,
|
|
6
|
+
// goals shipped together, and comms/mentions once C06 mirrors chat in — not a
|
|
7
|
+
// single signal. The owner took the blend knowing its risk, and attached two
|
|
8
|
+
// conditions that are part of the deliverable rather than follow-ups:
|
|
9
|
+
//
|
|
10
|
+
// 1. THE WEIGHTS ARE DATA. They live in ./closeness-weights.json and are served
|
|
11
|
+
// verbatim by the route, so the model can be reviewed and changed without a
|
|
12
|
+
// code-archaeology dig. Nothing in this file hard-codes a weight.
|
|
13
|
+
//
|
|
14
|
+
// 2. "WHY AM I CLOSE TO THEM" IS ANSWERABLE. Every score carries its own
|
|
15
|
+
// breakdown: per signal, the raw count, what it was divided by, its weight,
|
|
16
|
+
// and what it contributed. A number that ranks real colleagues and cannot say
|
|
17
|
+
// what it is made of becomes unfair with nothing to point at, which is
|
|
18
|
+
// exactly what was raised when the blend was chosen.
|
|
19
|
+
//
|
|
20
|
+
// AN UNAVAILABLE SIGNAL IS NOT A ZERO. comms_mention cannot be measured until the
|
|
21
|
+
// C06 mirror exists. Scoring it as zero would depress every pair identically AND
|
|
22
|
+
// make the explanation say "you never talk", which is not what is true — what is
|
|
23
|
+
// true is that nothing here can see whether you talk. So unavailable signals are
|
|
24
|
+
// dropped and the remaining weights are renormalised, and the breakdown names what
|
|
25
|
+
// was excluded and why.
|
|
26
|
+
//
|
|
27
|
+
// CONSENT. ADR 0171's posture is fail-closed account state with the hide flag
|
|
28
|
+
// enforced in the READ path (task 1002277) — a builder who hid their profile must
|
|
29
|
+
// not reappear as a node in someone else's graph. This file routes EVERY graph
|
|
30
|
+
// read through one gate, `applyConsent`, rather than filtering at each call site,
|
|
31
|
+
// so C05 (task 1003175, "consent is the read path, not a filter bolted on after")
|
|
32
|
+
// has a single structural place to make it so. The gate FAILS CLOSED on error:
|
|
33
|
+
// a consent read that throws drops the graph to empty rather than serving it.
|
|
34
|
+
//
|
|
35
|
+
// A KNOWN PERFORMANCE GAP, measured rather than assumed. The signal joins key on
|
|
36
|
+
// tasks.goal_id (NO index), claims.builder_id and claims.task_id (indexed only
|
|
37
|
+
// partially, on ACTIVE claims — closeness reads history) and tasks.shipped_by
|
|
38
|
+
// (partial too). At today's size that is fine; it scales with total table size
|
|
39
|
+
// rather than goal size. The indexes belong in a core_ migration, not here — a
|
|
40
|
+
// module migration may only touch its own lifecycle_ tables (ADR 0083 §5), and
|
|
41
|
+
// the fitness check enforces it. Filed with the SQL as task 1003268.
|
|
42
|
+
//
|
|
43
|
+
// Reaches core only through the doorway (ADR 0083). The core tables it reads
|
|
44
|
+
// (claims, tasks, session_logs, builders) are read-only here.
|
|
45
|
+
|
|
46
|
+
'use strict';
|
|
47
|
+
|
|
48
|
+
const { pool } = require('../../src/module-api');
|
|
49
|
+
const WEIGHTS = require('./closeness-weights.json');
|
|
50
|
+
|
|
51
|
+
const MAX_ROWS = 100;
|
|
52
|
+
// Signals are counted over a trailing window: a collaboration from two years ago
|
|
53
|
+
// is history, not closeness. Long enough that a slow-moving goal still counts.
|
|
54
|
+
const WINDOW_DAYS = 365;
|
|
55
|
+
|
|
56
|
+
function capRows(n) {
|
|
57
|
+
const v = Number(n);
|
|
58
|
+
return Number.isInteger(v) && v > 0 && v <= MAX_ROWS ? v : MAX_ROWS;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// The model
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
// The weights file, as loaded. Exposed so the route can serve it verbatim — the
|
|
66
|
+
// audit surface must show the SAME data the scorer used, not a restatement.
|
|
67
|
+
function weightsModel() { return WEIGHTS; }
|
|
68
|
+
|
|
69
|
+
function availableSignals() { return WEIGHTS.signals.filter((s) => s.available === true); }
|
|
70
|
+
function unavailableSignals() { return WEIGHTS.signals.filter((s) => s.available !== true); }
|
|
71
|
+
|
|
72
|
+
// PURE. counts -> { score, breakdown, excluded }.
|
|
73
|
+
//
|
|
74
|
+
// `counts` is { [signalKey]: number }. Missing keys are 0 — a signal that IS
|
|
75
|
+
// measurable and simply did not happen is a real zero, unlike an unavailable one.
|
|
76
|
+
//
|
|
77
|
+
// Returns:
|
|
78
|
+
// score — 0..1, the weighted sum over AVAILABLE signals with weights
|
|
79
|
+
// renormalised to sum to 1 across that subset.
|
|
80
|
+
// breakdown — one row per available signal: the count, the saturation it was
|
|
81
|
+
// measured against, the clamped value, the effective (renormalised)
|
|
82
|
+
// weight, and the contribution to the score. This IS the answer to
|
|
83
|
+
// "why am I close to them".
|
|
84
|
+
// excluded — the unavailable signals, each with the reason, so a surface can
|
|
85
|
+
// say what the number could not see.
|
|
86
|
+
function scoreCloseness(counts = {}) {
|
|
87
|
+
const avail = availableSignals();
|
|
88
|
+
const totalWeight = avail.reduce((sum, s) => sum + (Number(s.weight) || 0), 0);
|
|
89
|
+
|
|
90
|
+
// No available signal, or weights that sum to nothing, means there is no model
|
|
91
|
+
// to score with. Answer 0 with an empty breakdown rather than dividing by zero
|
|
92
|
+
// and returning NaN dressed up as a closeness.
|
|
93
|
+
if (!avail.length || totalWeight <= 0) {
|
|
94
|
+
return { score: 0, breakdown: [], excluded: unavailableSignals().map(excludedRow), scorable: false };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Contributions are computed at full precision and the TOTAL is rounded once.
|
|
98
|
+
// Summing already-rounded rows instead lost a ten-thousandth per signal, so a
|
|
99
|
+
// pair at full on every available signal scored 0.9999 rather than 1 — a score
|
|
100
|
+
// that can never reach its own maximum is a small lie the explanation would
|
|
101
|
+
// then have to carry.
|
|
102
|
+
let raw = 0;
|
|
103
|
+
const breakdown = avail.map((s) => {
|
|
104
|
+
const count = Math.max(0, Number(counts[s.key]) || 0);
|
|
105
|
+
const saturation = Math.max(1, Number(s.saturation) || 1);
|
|
106
|
+
const value = Math.min(count / saturation, 1);
|
|
107
|
+
const effectiveWeight = (Number(s.weight) || 0) / totalWeight;
|
|
108
|
+
raw += value * effectiveWeight;
|
|
109
|
+
return {
|
|
110
|
+
key: s.key,
|
|
111
|
+
label: s.label,
|
|
112
|
+
description: s.description,
|
|
113
|
+
count,
|
|
114
|
+
saturation,
|
|
115
|
+
value: round4(value),
|
|
116
|
+
declared_weight: Number(s.weight) || 0,
|
|
117
|
+
effective_weight: round4(effectiveWeight),
|
|
118
|
+
contribution: round4(value * effectiveWeight),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const score = round4(raw);
|
|
123
|
+
return { score, breakdown, excluded: unavailableSignals().map(excludedRow), scorable: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function excludedRow(s) {
|
|
127
|
+
return {
|
|
128
|
+
key: s.key,
|
|
129
|
+
label: s.label,
|
|
130
|
+
declared_weight: Number(s.weight) || 0,
|
|
131
|
+
reason: 'not_measurable_yet',
|
|
132
|
+
// Why this matters, in the payload rather than only in a comment: a surface
|
|
133
|
+
// that shows a score must be able to say what the score could not see.
|
|
134
|
+
explanation: `This signal cannot be measured yet, so it was excluded and its weight redistributed across the others — it was NOT counted as zero.${s.blocked_by ? ` Waiting on ${s.blocked_by}.` : ''}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function round4(n) { return Math.round(n * 10000) / 10000; }
|
|
139
|
+
|
|
140
|
+
// A one-sentence rendering of a breakdown, for a surface with no room for a table.
|
|
141
|
+
// PURE, and built from the SAME rows the score came from, so the sentence cannot
|
|
142
|
+
// drift from the arithmetic.
|
|
143
|
+
function explainInWords(scored) {
|
|
144
|
+
if (!scored || !scored.scorable) return 'There is no closeness model available to score this pair.';
|
|
145
|
+
const contributing = scored.breakdown.filter((r) => r.count > 0);
|
|
146
|
+
if (!contributing.length) return 'Nothing measurable has happened between you yet.';
|
|
147
|
+
const parts = contributing
|
|
148
|
+
.slice()
|
|
149
|
+
.sort((a, b) => b.contribution - a.contribution)
|
|
150
|
+
.map((r) => `${r.label.toLowerCase()} (${r.count})`);
|
|
151
|
+
const tail = scored.excluded.length
|
|
152
|
+
? ` Not counted: ${scored.excluded.map((e) => e.label.toLowerCase()).join(', ')} — not measurable yet.`
|
|
153
|
+
: '';
|
|
154
|
+
return `Mostly ${parts.join(', then ')}.${tail}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// The signals, read from the live tables
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
//
|
|
161
|
+
// Each read answers ONE question for one builder, and returns a Map of
|
|
162
|
+
// otherBuilderId -> count. They are separate queries rather than one join so a
|
|
163
|
+
// signal can be added, removed, or reweighted without touching the others — the
|
|
164
|
+
// same reason the weights are data.
|
|
165
|
+
//
|
|
166
|
+
// EACH TAKES AN OPTIONAL `otherId`, which narrows the same query to a single pair
|
|
167
|
+
// rather than the whole graph. That is deliberately a PARAMETER and not a second
|
|
168
|
+
// set of pair-shaped functions: two hand-written definitions of "what a co-claim
|
|
169
|
+
// is" would drift, and the one that drifted would be the one nobody reads. The
|
|
170
|
+
// graph read passes null; closenessBetween passes the id.
|
|
171
|
+
|
|
172
|
+
// co_claim — claims by the two builders on tasks in the SAME GOAL whose active
|
|
173
|
+
// windows overlapped. `released_at IS NULL` means still open, so COALESCE to now().
|
|
174
|
+
async function coClaimCounts(builderId, { otherId = null } = {}, deps = {}) {
|
|
175
|
+
const activePool = deps.pool || pool;
|
|
176
|
+
const { rows } = await activePool.query(
|
|
177
|
+
`SELECT other.builder_id AS other_id, count(DISTINCT t_mine.goal_id)::int AS n
|
|
178
|
+
FROM claims mine
|
|
179
|
+
JOIN tasks t_mine ON t_mine.id = mine.task_id AND t_mine.goal_id IS NOT NULL
|
|
180
|
+
JOIN tasks t_other ON t_other.goal_id = t_mine.goal_id
|
|
181
|
+
JOIN claims other ON other.task_id = t_other.id AND other.builder_id <> $1
|
|
182
|
+
WHERE mine.builder_id = $1
|
|
183
|
+
AND ($3::bigint IS NULL OR other.builder_id = $3)
|
|
184
|
+
AND mine.claimed_at > now() - ($2 || ' days')::interval
|
|
185
|
+
AND tstzrange(mine.claimed_at, COALESCE(mine.released_at, now()))
|
|
186
|
+
&& tstzrange(other.claimed_at, COALESCE(other.released_at, now()))
|
|
187
|
+
GROUP BY other.builder_id`,
|
|
188
|
+
[builderId, String(WINDOW_DAYS), otherId]
|
|
189
|
+
);
|
|
190
|
+
return toCountMap(rows);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// co_build_session — logged work sessions on tasks in the same goal that overlapped
|
|
194
|
+
// in time. Distinct from co_claim on purpose: a claim can sit open for days, a
|
|
195
|
+
// session is the hours actually spent, so this is the closer measure of presence.
|
|
196
|
+
async function coSessionCounts(builderId, { otherId = null } = {}, deps = {}) {
|
|
197
|
+
const activePool = deps.pool || pool;
|
|
198
|
+
const { rows } = await activePool.query(
|
|
199
|
+
`SELECT other.builder_id AS other_id, count(*)::int AS n
|
|
200
|
+
FROM session_logs mine
|
|
201
|
+
JOIN tasks t_mine ON t_mine.id = mine.task_id AND t_mine.goal_id IS NOT NULL
|
|
202
|
+
JOIN tasks t_other ON t_other.goal_id = t_mine.goal_id
|
|
203
|
+
JOIN session_logs other ON other.task_id = t_other.id AND other.builder_id <> $1
|
|
204
|
+
WHERE mine.builder_id = $1
|
|
205
|
+
AND ($3::bigint IS NULL OR other.builder_id = $3)
|
|
206
|
+
AND mine.started_at > now() - ($2 || ' days')::interval
|
|
207
|
+
AND tstzrange(mine.started_at, mine.ended_at)
|
|
208
|
+
&& tstzrange(other.started_at, other.ended_at)
|
|
209
|
+
GROUP BY other.builder_id`,
|
|
210
|
+
[builderId, String(WINDOW_DAYS), otherId]
|
|
211
|
+
);
|
|
212
|
+
return toCountMap(rows);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// shipped_together_goal — DISTINCT goals into which both have shipped at least one
|
|
216
|
+
// task. Timing is deliberately ignored: this is the durable record that you both
|
|
217
|
+
// built the same thing, even months apart.
|
|
218
|
+
async function shippedTogetherCounts(builderId, { otherId = null } = {}, deps = {}) {
|
|
219
|
+
const activePool = deps.pool || pool;
|
|
220
|
+
const { rows } = await activePool.query(
|
|
221
|
+
`SELECT t_other.shipped_by AS other_id, count(DISTINCT t_mine.goal_id)::int AS n
|
|
222
|
+
FROM tasks t_mine
|
|
223
|
+
JOIN tasks t_other ON t_other.goal_id = t_mine.goal_id
|
|
224
|
+
WHERE t_mine.shipped_by = $1
|
|
225
|
+
AND t_mine.goal_id IS NOT NULL
|
|
226
|
+
AND t_mine.shipped_at > now() - ($2 || ' days')::interval
|
|
227
|
+
AND t_other.shipped_by IS NOT NULL
|
|
228
|
+
AND t_other.shipped_by <> $1
|
|
229
|
+
AND ($3::bigint IS NULL OR t_other.shipped_by = $3)
|
|
230
|
+
GROUP BY t_other.shipped_by`,
|
|
231
|
+
[builderId, String(WINDOW_DAYS), otherId]
|
|
232
|
+
);
|
|
233
|
+
return toCountMap(rows);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function toCountMap(rows) {
|
|
237
|
+
const m = new Map();
|
|
238
|
+
for (const r of rows) {
|
|
239
|
+
if (r.other_id == null) continue;
|
|
240
|
+
m.set(String(r.other_id), Number(r.n) || 0);
|
|
241
|
+
}
|
|
242
|
+
return m;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Consent — ONE gate, fail-closed
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
//
|
|
249
|
+
// Every graph read passes through here, so C05 has a single structural place to
|
|
250
|
+
// make consent the read path rather than a filter bolted on at each call site.
|
|
251
|
+
//
|
|
252
|
+
// `hiddenIdsFor` is injected: the platform-identity hub owns the hide flag
|
|
253
|
+
// (ADR 0171 D2/D4) and lifecycle must not reach across a module boundary to read
|
|
254
|
+
// it (ADR 0083). The route resolves the port and passes a reader in; where no hub
|
|
255
|
+
// is enabled there is no hub-level hide flag to honour, and NOTHING is suppressed
|
|
256
|
+
// by this mechanism — which is correct, not a hole: on such an instance nobody has
|
|
257
|
+
// been offered the switch. What is NOT tolerated is an ERROR being read as
|
|
258
|
+
// "nobody is hidden", so a throwing reader empties the graph.
|
|
259
|
+
async function applyConsent(entries, hiddenIdsFor) {
|
|
260
|
+
if (typeof hiddenIdsFor !== 'function') return entries;
|
|
261
|
+
let hidden;
|
|
262
|
+
try {
|
|
263
|
+
hidden = await hiddenIdsFor(entries.map((e) => e.builder_id));
|
|
264
|
+
} catch (_) {
|
|
265
|
+
// FAIL CLOSED. An unreadable consent state is not permission.
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
if (!hidden) return []; // ditto: null is not "nobody"
|
|
269
|
+
const set = hidden instanceof Set ? hidden : new Set((hidden || []).map(String));
|
|
270
|
+
return entries.filter((e) => !set.has(String(e.builder_id)));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// The graph
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
// Everyone this builder has any measurable closeness to, scored and explained,
|
|
278
|
+
// strongest first. `hiddenIdsFor` is the consent gate above.
|
|
279
|
+
// `otherId` narrows every signal query to one pair at the DATABASE, so the pair
|
|
280
|
+
// read does not fetch a hundred rows to keep one. It is threaded through rather
|
|
281
|
+
// than filtered afterwards — see the note above the signal reads.
|
|
282
|
+
async function closenessFor(builderId, { limit = MAX_ROWS, hiddenIdsFor, otherId = null } = {}, deps = {}) {
|
|
283
|
+
const activePool = deps.pool || pool;
|
|
284
|
+
const [coClaim, coSession, shipped] = await Promise.all([
|
|
285
|
+
coClaimCounts(builderId, { otherId }, { pool: activePool }),
|
|
286
|
+
coSessionCounts(builderId, { otherId }, { pool: activePool }),
|
|
287
|
+
shippedTogetherCounts(builderId, { otherId }, { pool: activePool }),
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
const ids = new Set([...coClaim.keys(), ...coSession.keys(), ...shipped.keys()]);
|
|
291
|
+
let entries = [...ids].map((otherId) => {
|
|
292
|
+
const counts = {
|
|
293
|
+
co_claim: coClaim.get(otherId) || 0,
|
|
294
|
+
co_build_session: coSession.get(otherId) || 0,
|
|
295
|
+
shipped_together_goal: shipped.get(otherId) || 0,
|
|
296
|
+
};
|
|
297
|
+
const scored = scoreCloseness(counts);
|
|
298
|
+
return {
|
|
299
|
+
builder_id: otherId,
|
|
300
|
+
score: scored.score,
|
|
301
|
+
// The explanation travels WITH the score, on every surface, rather than
|
|
302
|
+
// behind a second call a caller may not make. That is the decision.
|
|
303
|
+
why: { breakdown: scored.breakdown, excluded: scored.excluded, in_words: explainInWords(scored) },
|
|
304
|
+
};
|
|
305
|
+
}).filter((e) => e.score > 0);
|
|
306
|
+
|
|
307
|
+
entries = await applyConsent(entries, hiddenIdsFor);
|
|
308
|
+
entries.sort((a, b) => b.score - a.score || String(a.builder_id).localeCompare(String(b.builder_id)));
|
|
309
|
+
entries = entries.slice(0, capRows(limit));
|
|
310
|
+
|
|
311
|
+
return hydrate(entries, activePool);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// One pair, with the same breakdown the graph carries. Separate from closenessFor
|
|
315
|
+
// so "why am I close to THEM" is answerable directly, without paging a graph.
|
|
316
|
+
async function closenessBetween(builderId, otherId, { hiddenIdsFor } = {}, deps = {}) {
|
|
317
|
+
const activePool = deps.pool || pool;
|
|
318
|
+
if (String(builderId) === String(otherId)) return null;
|
|
319
|
+
// NARROWED AT THE DATABASE, not filtered in memory. This used to call the graph
|
|
320
|
+
// read and then find() the one row it wanted, which ran three self-joins and a
|
|
321
|
+
// hydrate over up to a hundred builders to answer a question about one — on
|
|
322
|
+
// what is likely a per-profile-view route. Passing otherId pushes the narrowing
|
|
323
|
+
// into the same queries, so there is still exactly one definition of each
|
|
324
|
+
// signal and the pair read costs a pair.
|
|
325
|
+
const graph = await closenessFor(builderId, { limit: 1, hiddenIdsFor, otherId }, { pool: activePool });
|
|
326
|
+
const found = graph.find((e) => String(e.builder_id) === String(otherId));
|
|
327
|
+
if (found) return found;
|
|
328
|
+
|
|
329
|
+
// Not in the graph. That is either "no measurable closeness" or "consent says
|
|
330
|
+
// no", and this deliberately does not distinguish them: a caller able to tell
|
|
331
|
+
// the two apart could detect that a specific builder has hidden themselves,
|
|
332
|
+
// which is the thing the hide flag exists to prevent.
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Attach the names a surface needs. Done once, after scoring and consent, so a
|
|
337
|
+
// suppressed builder's identity is never read at all.
|
|
338
|
+
async function hydrate(entries, activePool) {
|
|
339
|
+
if (!entries.length) return [];
|
|
340
|
+
const { rows } = await activePool.query(
|
|
341
|
+
`SELECT id, github_login, display_name, avatar_url
|
|
342
|
+
FROM builders WHERE id = ANY($1::bigint[])`,
|
|
343
|
+
[entries.map((e) => e.builder_id)]
|
|
344
|
+
);
|
|
345
|
+
const byId = new Map(rows.map((r) => [String(r.id), r]));
|
|
346
|
+
return entries.map((e) => {
|
|
347
|
+
const b = byId.get(String(e.builder_id)) || {};
|
|
348
|
+
return {
|
|
349
|
+
...e,
|
|
350
|
+
github_login: b.github_login || null,
|
|
351
|
+
display_name: b.display_name || null,
|
|
352
|
+
avatar_url: b.avatar_url || null,
|
|
353
|
+
};
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = {
|
|
358
|
+
WINDOW_DAYS, MAX_ROWS, capRows,
|
|
359
|
+
weightsModel, availableSignals, unavailableSignals,
|
|
360
|
+
scoreCloseness, explainInWords,
|
|
361
|
+
coClaimCounts, coSessionCounts, shippedTogetherCounts,
|
|
362
|
+
applyConsent, closenessFor, closenessBetween,
|
|
363
|
+
};
|
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
// modules/lifecycle/routes/claims.js — locking a task to one builder's session,
|
|
2
|
+
// and letting go of it again (ADR 0095 backfill).
|
|
3
|
+
//
|
|
4
|
+
// WHY. The claim is what makes PARALLEL builder sessions safe. Several agents work
|
|
5
|
+
// this repo at once; without an atomic lock two of them pick the same task, write
|
|
6
|
+
// the same files, and one loses the work at merge. `POST /claims` is that lock, and
|
|
7
|
+
// it must stay atomic — it refuses with a typed reason rather than racing: already
|
|
8
|
+
// claimed, task not ready, touches conflict, or 409 `DEPS_NOT_SHIPPED` when the
|
|
9
|
+
// dependency graph has not opened the task yet.
|
|
10
|
+
//
|
|
11
|
+
// Every claim ends deliberately, never by drifting: `/claims/:id/resolve` carries
|
|
12
|
+
// both the ship path (credits land) and the release path (no credits, task returns
|
|
13
|
+
// to `ready`). `release-on-behalf` is the privileged escape hatch for a claim whose
|
|
14
|
+
// builder is gone — a crashed session must not strand a task forever.
|
|
15
|
+
//
|
|
16
|
+
// The batch routes serve the multi-claim session flow; `batch/validate` is a DRY
|
|
17
|
+
// RUN, so a builder can see which of several tasks would be refused before locking
|
|
18
|
+
// any of them.
|
|
19
|
+
//
|
|
20
|
+
// MODULE BOUNDARY: reaches core only through the published doorway
|
|
21
|
+
// `src/module-api.js`; never a core internal, never another module.
|
|
22
|
+
|
|
1
23
|
const express = require('express');
|
|
2
24
|
// Intra-module + the published doorway — a module never requires a core internal.
|
|
3
25
|
const api = require('../../../src/module-api');
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// modules/lifecycle/routes/closeness.js — the closeness graph, and the surface
|
|
2
|
+
// that can explain it (task 1003174 / C04, goal 1000075; criterion
|
|
3
|
+
// collab-closeness-consented, ADR 0187 §2).
|
|
4
|
+
//
|
|
5
|
+
// THREE ROUTES, AND ONE OF THEM IS THE POINT:
|
|
6
|
+
// GET /closeness/weights — the scoring model, served VERBATIM from the
|
|
7
|
+
// data file. The owner's condition was that
|
|
8
|
+
// the weights be reviewable without a code
|
|
9
|
+
// dig; this is where they are reviewed.
|
|
10
|
+
// GET /closeness/for-me — my graph. Every entry carries its own `why`.
|
|
11
|
+
// GET /closeness/with/:builderId — one pair, same breakdown, asked directly.
|
|
12
|
+
//
|
|
13
|
+
// THE EXPLANATION IS NOT A SEPARATE ENDPOINT, deliberately. "Why am I close to
|
|
14
|
+
// them" must be answerable on ANY surface that shows closeness, and a second call
|
|
15
|
+
// a caller may simply not make is how a score ends up on a page with nothing to
|
|
16
|
+
// point at. So `why` rides in the same payload as the number, always.
|
|
17
|
+
//
|
|
18
|
+
// CONSENT is resolved here and passed INTO the read as a gate (see
|
|
19
|
+
// closeness.js applyConsent), never applied to the output afterwards. The hub owns
|
|
20
|
+
// the hide flag (ADR 0171 D2/D4) and lifecycle must not read another module's
|
|
21
|
+
// table (ADR 0083), so the port is resolved optionally and a reader is handed down.
|
|
22
|
+
//
|
|
23
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const express = require('express');
|
|
28
|
+
const api = require('../../../src/module-api');
|
|
29
|
+
const closeness = require('../closeness');
|
|
30
|
+
|
|
31
|
+
const { asyncHandler, parseId } = api;
|
|
32
|
+
|
|
33
|
+
// Build the consent reader the graph gates on, or null where no hub is enabled.
|
|
34
|
+
//
|
|
35
|
+
// NULL IS NOT A HOLE. Where the platform-identity module is off there is no
|
|
36
|
+
// hub-level hide flag to honour, because nobody on that instance has been offered
|
|
37
|
+
// the switch — suppressing everyone would not be consent, it would be an outage.
|
|
38
|
+
// What must never happen is an ERROR being read as "nobody is hidden", and that is
|
|
39
|
+
// why the reader THROWS rather than swallowing: applyConsent catches and empties
|
|
40
|
+
// the graph, which is the fail-closed direction ADR 0171 asks for.
|
|
41
|
+
function consentReader(pool) {
|
|
42
|
+
const hub = api.resolveOptional('platform-identity.hub');
|
|
43
|
+
if (!hub || typeof hub.hiddenGithubIds !== 'function') return null;
|
|
44
|
+
|
|
45
|
+
return async (builderIds) => {
|
|
46
|
+
if (!builderIds || !builderIds.length) return new Set();
|
|
47
|
+
// builder_id -> github_id, from the CORE builders table (a read-only core
|
|
48
|
+
// read, which the module contract permits; the hub keys consent by github_id
|
|
49
|
+
// and cannot see our builder ids).
|
|
50
|
+
const { rows } = await pool.query(
|
|
51
|
+
'SELECT id, github_id FROM builders WHERE id = ANY($1::bigint[])',
|
|
52
|
+
[builderIds]
|
|
53
|
+
);
|
|
54
|
+
const githubIdByBuilder = new Map(rows.map((r) => [String(r.id), r.github_id]));
|
|
55
|
+
const hiddenGithub = await hub.hiddenGithubIds([...githubIdByBuilder.values()].filter((g) => g != null));
|
|
56
|
+
const hiddenSet = hiddenGithub instanceof Set ? hiddenGithub : new Set((hiddenGithub || []).map(String));
|
|
57
|
+
const hidden = new Set();
|
|
58
|
+
for (const [builderId, githubId] of githubIdByBuilder) {
|
|
59
|
+
if (githubId != null && hiddenSet.has(String(githubId))) hidden.add(builderId);
|
|
60
|
+
}
|
|
61
|
+
return hidden;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = function buildClosenessRouter() {
|
|
66
|
+
const router = express.Router();
|
|
67
|
+
|
|
68
|
+
// rank: any-builder — the MODEL is not private, and publishing it is the whole
|
|
69
|
+
// point of the weights being data. It describes how the system scores people;
|
|
70
|
+
// a builder ranked by it is entitled to read it.
|
|
71
|
+
router.get('/closeness/weights', api.requireBuilder, asyncHandler('GET /closeness/weights', async (req, res) => {
|
|
72
|
+
// Served verbatim from the file the scorer loads, NOT restated here — an
|
|
73
|
+
// audit surface that paraphrases the model can drift from it, which would
|
|
74
|
+
// make it worse than nothing.
|
|
75
|
+
return res.json({
|
|
76
|
+
weights: closeness.weightsModel(),
|
|
77
|
+
window_days: closeness.WINDOW_DAYS,
|
|
78
|
+
// Named explicitly so a reader does not have to infer the renormalisation
|
|
79
|
+
// rule from the numbers.
|
|
80
|
+
note: 'Signals marked available:false are EXCLUDED and their weight is redistributed across the rest — they are never counted as zero. Each score carries the same breakdown this model produces.',
|
|
81
|
+
});
|
|
82
|
+
}, { errorCode: 'closeness_weights_failed' }));
|
|
83
|
+
|
|
84
|
+
// rank: any-builder, and OWN-SCOPED — your own graph, never someone else's.
|
|
85
|
+
// Reading another builder's graph would expose who THEY are close to, which is
|
|
86
|
+
// a fact about third parties they did not consent to share.
|
|
87
|
+
router.get('/closeness/for-me', api.requireBuilder, asyncHandler('GET /closeness/for-me', async (req, res) => {
|
|
88
|
+
const rows = await closeness.closenessFor(req.builder.id, {
|
|
89
|
+
hiddenIdsFor: consentReader(api.pool),
|
|
90
|
+
});
|
|
91
|
+
return res.json({ closeness: rows, count: rows.length });
|
|
92
|
+
}, { errorCode: 'closeness_failed' }));
|
|
93
|
+
|
|
94
|
+
// One pair — "why am I close to THEM", asked directly rather than by paging the
|
|
95
|
+
// graph. Always relative to the CALLER: the pair is (you, them).
|
|
96
|
+
//
|
|
97
|
+
// rank: any-builder.
|
|
98
|
+
router.get('/closeness/with/:builderId', api.requireBuilder, asyncHandler('GET /closeness/with/:builderId', async (req, res) => {
|
|
99
|
+
const otherId = parseId(req, res, { param: 'builderId', code: 'bad_builder_id', positiveInt: true });
|
|
100
|
+
if (otherId === null) return;
|
|
101
|
+
|
|
102
|
+
const pair = await closeness.closenessBetween(req.builder.id, otherId, {
|
|
103
|
+
hiddenIdsFor: consentReader(api.pool),
|
|
104
|
+
});
|
|
105
|
+
if (!pair) {
|
|
106
|
+
// ONE answer for "nothing measurable happened" AND "consent says no".
|
|
107
|
+
// Distinguishing them would let a caller detect that a specific builder has
|
|
108
|
+
// hidden themselves — the exact thing the hide flag exists to prevent.
|
|
109
|
+
return res.json({
|
|
110
|
+
closeness: null,
|
|
111
|
+
why: { in_words: 'Nothing measurable connects you two.', breakdown: [], excluded: [] },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return res.json({ closeness: pair, why: pair.why });
|
|
115
|
+
}, { errorCode: 'closeness_failed' }));
|
|
116
|
+
|
|
117
|
+
return router;
|
|
118
|
+
};
|
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
// modules/lifecycle/routes/tasks.js — the HTTP surface of the task itself: what
|
|
2
|
+
// work exists, what is claimable, and how a task moves through its statuses
|
|
3
|
+
// (ADR 0095 backfill).
|
|
4
|
+
//
|
|
5
|
+
// WHY. A task is the unit the whole methodology rests on — "every change is backed
|
|
6
|
+
// by a claimed task" only means something if the task's shape and its status
|
|
7
|
+
// transitions are enforced in ONE place. This is that place. It owns the
|
|
8
|
+
// backlog→ready→active→completed→confirmed→shipped walk (`/promote`, `/demote`,
|
|
9
|
+
// `/ship`, `/confirm`, `/abandon`), the DEPENDENCY graph that gates claimability,
|
|
10
|
+
// and the done-when criteria a version's progress rolls up from.
|
|
11
|
+
//
|
|
12
|
+
// Two invariants that are easy to break from here:
|
|
13
|
+
// - `/tasks/claimable` computes parallel-safety against everyone's ACTIVE claims.
|
|
14
|
+
// It is what makes concurrent builder sessions safe; a filter loosened here
|
|
15
|
+
// hands two builders the same work.
|
|
16
|
+
// - Dependencies are load-bearing; the `V##.R##` rank prefix is decorative.
|
|
17
|
+
// Shipping a task auto-promotes its now-unblocked dependents backlog→ready
|
|
18
|
+
// (ADR 0015), so the graph — not a human — decides what opens up next.
|
|
19
|
+
//
|
|
20
|
+
// MODULE BOUNDARY: reaches core only through the published doorway
|
|
21
|
+
// `src/module-api.js`; never a core internal, never another module.
|
|
22
|
+
|
|
1
23
|
const express = require('express');
|
|
2
24
|
const fs = require('node:fs');
|
|
3
25
|
const os = require('node:os');
|
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
// modules/lifecycle/routes/versions.js — the version as a container of scope, and
|
|
2
|
+
// the question "is it done yet?" (ADR 0095 backfill).
|
|
3
|
+
//
|
|
4
|
+
// WHY. The project ships version-by-version, so a version is not a label — it is
|
|
5
|
+
// the unit scope is committed to and closed against. These routes answer the two
|
|
6
|
+
// things anyone asks about one: what is IN it (`GET /versions`, `POST /versions`)
|
|
7
|
+
// and how far along it is (`/progress`, `/:id/progress` — the roll-up `/status`
|
|
8
|
+
// renders: each done-when criterion → its gating tasks → what remains, ADR 0025).
|
|
9
|
+
//
|
|
10
|
+
// `/versions/close-suggestions` exists because a version that is *actually*
|
|
11
|
+
// finished rarely announces itself — work drifts, criteria get met out of order,
|
|
12
|
+
// and a version can sit open long after its last real task shipped. Delegated to
|
|
13
|
+
// `../version-close.js`, it surfaces the candidates so closing is a decision
|
|
14
|
+
// someone makes rather than one nobody remembers to make.
|
|
15
|
+
//
|
|
16
|
+
// MODULE BOUNDARY: reaches core only through the published doorway
|
|
17
|
+
// `src/module-api.js`; never a core internal, never another module.
|
|
18
|
+
|
|
1
19
|
const express = require('express');
|
|
2
20
|
// Intra-module + the published doorway — a module never requires a core internal.
|
|
3
21
|
const api = require('../../../src/module-api');
|
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
// modules/memory/routes/learnings.js — GET/POST /learnings: pay for a discovery
|
|
2
|
+
// once (ADR 0095 backfill).
|
|
3
|
+
//
|
|
4
|
+
// WHY. A hard-won diagnosis — the kind that costs half an hour of a session — is
|
|
5
|
+
// worth exactly nothing if the next agent has to rediscover it. Learnings are the
|
|
6
|
+
// short-form half of that cure (the long-form half is a recipe under
|
|
7
|
+
// `docs/recipes/`): one durable, searchable rule per finding, captured via
|
|
8
|
+
// `node scripts/gds/learning-capture.js` and surfaced again through `/recall`.
|
|
9
|
+
//
|
|
10
|
+
// Capture is open to any signed-in builder on purpose. The agent that just paid
|
|
11
|
+
// for the discovery is the one holding it, and a rank gate on writing down what
|
|
12
|
+
// you learned would simply mean it never gets written down.
|
|
13
|
+
//
|
|
14
|
+
// PRIVACY: bodies pass through `stripPrivate()` before persisting, so anything
|
|
15
|
+
// fenced in `<private>…</private>` never enters the store or the recall index
|
|
16
|
+
// (ADR 0095 C4). The fence is fail-closed — an unbalanced marker strips to the end
|
|
17
|
+
// rather than leaking what it was meant to hide.
|
|
18
|
+
//
|
|
19
|
+
// MODULE BOUNDARY: reaches core only through the published doorway
|
|
20
|
+
// `src/module-api.js`; `../learnings` is an intra-module sibling.
|
|
21
|
+
|
|
1
22
|
const express = require('express');
|
|
2
23
|
// Carved into modules/memory/ (BV1.R72 / ADR 0091 §2): auth + the shared request
|
|
3
24
|
// validators (validateOrRespond/LIMITS) via the doorway; the learnings lib is an
|
|
@@ -629,6 +629,33 @@ async function getPublicProfileExtras(githubId, { pool = defaultPool } = {}) {
|
|
|
629
629
|
return getRollupForBuilder(githubId, { pool });
|
|
630
630
|
}
|
|
631
631
|
|
|
632
|
+
// The CONSENT read, in bulk (task 1003174 / C04, goal 1000075). Answers exactly
|
|
633
|
+
// one question — "which of these accounts have HIDDEN themselves?" — and returns
|
|
634
|
+
// a Set of the github_ids that have.
|
|
635
|
+
//
|
|
636
|
+
// WHY NOT REUSE getPublicProfileExtras. It returns null for a hidden account AND
|
|
637
|
+
// for one that is merely absent or still provisional, because for a profile
|
|
638
|
+
// rollup those cases are identical: there is nothing to show. For a consent gate
|
|
639
|
+
// they are opposites. A builder who has never federated at the hub has not opted
|
|
640
|
+
// OUT of anything — nobody offered them the switch — so treating their missing
|
|
641
|
+
// row as "hidden" would empty the closeness graph of every builder on a fresh
|
|
642
|
+
// instance and call it consent. HIDDEN means a row exists and hide_stats is true.
|
|
643
|
+
//
|
|
644
|
+
// FAIL-CLOSED is the CALLER's job here, and deliberately so: this function
|
|
645
|
+
// THROWS on a bad read rather than returning an empty set, because an empty set
|
|
646
|
+
// means "nobody is hidden" and a database error must never be able to say that.
|
|
647
|
+
// modules/lifecycle/closeness.js applyConsent catches and empties the graph.
|
|
648
|
+
async function hiddenGithubIds(githubIds, { pool = defaultPool } = {}) {
|
|
649
|
+
const ids = (Array.isArray(githubIds) ? githubIds : []).filter(isNumericGithubId).map(String);
|
|
650
|
+
if (!ids.length) return new Set();
|
|
651
|
+
const { rows } = await pool.query(
|
|
652
|
+
`SELECT github_id FROM platform_identity_accounts
|
|
653
|
+
WHERE github_id = ANY($1::bigint[]) AND hide_stats = true`,
|
|
654
|
+
[ids],
|
|
655
|
+
);
|
|
656
|
+
return new Set(rows.map((r) => String(r.github_id)));
|
|
657
|
+
}
|
|
658
|
+
|
|
632
659
|
// The SELF read (ADR 0171 D2's first narrow side door): a builder inspects their
|
|
633
660
|
// own numbers in Settings regardless of profile_state/hide_stats. Callers MUST
|
|
634
661
|
// key this by the authenticated session's github_id, never a caller-named one.
|
|
@@ -1069,6 +1096,7 @@ async function sweepExpiredCodes({ pool = defaultPool } = {}) {
|
|
|
1069
1096
|
}
|
|
1070
1097
|
|
|
1071
1098
|
module.exports = {
|
|
1099
|
+
hiddenGithubIds,
|
|
1072
1100
|
DEFAULT_CODE_TTL_MS,
|
|
1073
1101
|
// pure helpers
|
|
1074
1102
|
sha256hex,
|
|
@@ -110,6 +110,10 @@ function registerSeams() {
|
|
|
110
110
|
api.registerProvider('platform-identity.hub', {
|
|
111
111
|
getPublicProfileExtras: (githubId) => pi.getPublicProfileExtras(githubId, { pool }),
|
|
112
112
|
getOwnProfileExtras: (githubId) => pi.getOwnProfileExtras(githubId, { pool }),
|
|
113
|
+
// task 1003174 (C04): the bulk consent read the closeness graph gates on.
|
|
114
|
+
// THROWS on a bad read by design — an empty set means "nobody is hidden",
|
|
115
|
+
// which a database error must never be allowed to say.
|
|
116
|
+
hiddenGithubIds: (githubIds) => pi.hiddenGithubIds(githubIds, { pool }),
|
|
113
117
|
});
|
|
114
118
|
}
|
|
115
119
|
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.361",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.361",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|