@bongos/core 1.19.358 → 1.19.360

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.
@@ -251,6 +251,14 @@ function createClient(opts = {}) {
251
251
  // POST /claims/batch/validate — rank: any-builder — POST /claims/batch/validate
252
252
  postClaimsBatchValidate: (args) => request("POST", "/claims/batch/validate", { hasBody: true }, args),
253
253
  },
254
+ "closeness": {
255
+ // GET /closeness/for-me — rank: any-builder — GET /closeness/for-me
256
+ getClosenessForMe: (args) => request("GET", "/closeness/for-me", { hasBody: false }, args),
257
+ // GET /closeness/weights — rank: any-builder — GET /closeness/weights
258
+ getClosenessWeights: (args) => request("GET", "/closeness/weights", { hasBody: false }, args),
259
+ // GET /closeness/with/{builderId} — rank: any-builder — GET /closeness/with/:builderId
260
+ getClosenessWithBuilderId: (args) => request("GET", "/closeness/with/{builderId}", { hasBody: false }, args),
261
+ },
254
262
  "copyDesk": {
255
263
  // POST /copy-desk/flags — rank: any-builder — POST /copy-desk/flags
256
264
  postCopyDeskFlags: (args) => request("POST", "/copy-desk/flags", { hasBody: true }, args),
@@ -715,6 +723,16 @@ function createClient(opts = {}) {
715
723
  // POST /sso/token — rank: public — POST /sso/token
716
724
  postSsoToken: (args) => request("POST", "/sso/token", { hasBody: true }, args),
717
725
  },
726
+ "taskRecommendations": {
727
+ // POST /task-recommendations — rank: any-builder — POST /task-recommendations
728
+ postTaskRecommendations: (args) => request("POST", "/task-recommendations", { hasBody: true }, args),
729
+ // PATCH /task-recommendations/{id} — rank: any-builder — PATCH /task-recommendations/:id
730
+ patchTaskRecommendationsId: (args) => request("PATCH", "/task-recommendations/{id}", { hasBody: true }, args),
731
+ // GET /task-recommendations/for-me — rank: any-builder — GET /task-recommendations/for-me
732
+ getTaskRecommendationsForMe: (args) => request("GET", "/task-recommendations/for-me", { hasBody: false }, args),
733
+ // GET /task-recommendations/mine — rank: any-builder — GET /task-recommendations/mine
734
+ getTaskRecommendationsMine: (args) => request("GET", "/task-recommendations/mine", { hasBody: false }, args),
735
+ },
718
736
  "taskVisuals": {
719
737
  // GET /task-visuals/{name} — rank: public — GET /task-visuals/:name
720
738
  getTaskVisualsName: (args) => request("GET", "/task-visuals/{name}", { hasBody: false }, args),
@@ -760,6 +778,8 @@ function createClient(opts = {}) {
760
778
  postTasksIdPublishBranch: (args) => request("POST", "/tasks/{id}/publish-branch", { hasBody: true }, args),
761
779
  // GET /tasks/{id}/publish-status — rank: any-builder — GET /tasks/:id/publish-status
762
780
  getTasksIdPublishStatus: (args) => request("GET", "/tasks/{id}/publish-status", { hasBody: false }, args),
781
+ // GET /tasks/{id}/recommendations — rank: any-builder — GET /tasks/:id/recommendations
782
+ getTasksIdRecommendations: (args) => request("GET", "/tasks/{id}/recommendations", { hasBody: false }, args),
763
783
  // POST /tasks/{id}/ship — rank: any-builder — POST /tasks/:id/ship
764
784
  postTasksIdShip: (args) => request("POST", "/tasks/{id}/ship", { hasBody: true }, args),
765
785
  // DELETE /tasks/{id}/visual — rank: any-builder — DELETE /tasks/:id/visual
@@ -0,0 +1,64 @@
1
+ {
2
+ "_comment": [
3
+ "The closeness scoring model, as DATA (task 1003174 / C04, goal 1000075; criterion",
4
+ "collab-closeness-consented, ADR 0187 §2). The owner's decision was explicit that the",
5
+ "weights are data rather than constants buried in a function, so they can be reviewed",
6
+ "and changed without a code-archaeology dig. This file IS that review surface, and it",
7
+ "is served verbatim by GET /closeness/weights so the number can always be audited",
8
+ "against the model that produced it.",
9
+ "",
10
+ "HOW A SCORE IS BUILT. Each signal counts real events between two builders. That count",
11
+ "is divided by the signal's `saturation` and clamped to 1, so a signal is 'full' once",
12
+ "it has happened `saturation` times and no single runaway count can dominate. The",
13
+ "closeness score is the weighted sum of those clamped values, expressed 0..1.",
14
+ "",
15
+ "WEIGHTS ARE RENORMALISED OVER AVAILABLE SIGNALS. A signal with `available: false`",
16
+ "cannot be measured yet, and its weight is redistributed across the rest rather than",
17
+ "counted as zero. Counting it as zero would depress every pair's score identically and,",
18
+ "worse, the explanation would read as 'you never talk' when the truth is 'nothing here",
19
+ "can see whether you talk'. The explanation names every excluded signal for this reason.",
20
+ "",
21
+ "CHANGING A WEIGHT is a product decision about how the system describes real people to",
22
+ "each other. Weights should sum to 1 across ALL signals (the test asserts it); the",
23
+ "renormalisation handles the available subset at read time."
24
+ ],
25
+
26
+ "version": 1,
27
+ "scale": "0..1, rendered as 0..100 on any surface that shows a number",
28
+
29
+ "signals": [
30
+ {
31
+ "key": "co_claim",
32
+ "label": "Worked the same goal at the same time",
33
+ "description": "You each held an active claim on a task in the same goal, and those claims overlapped in time. This is the live version of working alongside someone: you were both holding work in the same place at once.",
34
+ "weight": 0.3,
35
+ "saturation": 5,
36
+ "available": true
37
+ },
38
+ {
39
+ "key": "co_build_session",
40
+ "label": "Sat down to it in the same stretch",
41
+ "description": "You logged work sessions on tasks in the same goal that overlapped in time. A claim can sit open for days; a session is the hours actually spent, so this is the closer measure of being present together. The owner's decision (ADR 0187 §3) is that the unit of collaboration is the GOAL, never a shared claim — nobody co-runs a claim, and this signal does not pretend they do.",
42
+ "weight": 0.3,
43
+ "saturation": 5,
44
+ "available": true
45
+ },
46
+ {
47
+ "key": "shipped_together_goal",
48
+ "label": "Shipped into the same goal",
49
+ "description": "Distinct goals where you have each shipped at least one task. Unlike the two above, this ignores timing entirely — it is the durable record that you both built the same thing, even months apart.",
50
+ "weight": 0.25,
51
+ "saturation": 3,
52
+ "available": true
53
+ },
54
+ {
55
+ "key": "comms_mention",
56
+ "label": "Talked to or mentioned each other",
57
+ "description": "Messages and mentions between you. NOT MEASURABLE YET: this needs the Discord-into-the-hall mirror, which is C06 of this goal (task 1003176) and is still an open decision. Until it lands this signal is unavailable and its weight is redistributed across the others — it is never silently counted as zero, because 'we cannot see your conversations' and 'you never speak' are different facts and only one of them is true.",
58
+ "weight": 0.15,
59
+ "saturation": 20,
60
+ "available": false,
61
+ "blocked_by": "task 1003176 (C06 — Discord chat mirrors INTO the hall)"
62
+ }
63
+ ]
64
+ }
@@ -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
+ };
@@ -19,6 +19,7 @@
19
19
  const db = require('./db');
20
20
  const classifier = require('./task-classifier');
21
21
  const helpRequests = require('./help-requests');
22
+ const taskRecommendations = require('./task-recommendations');
22
23
 
23
24
  module.exports = {
24
25
  // --- retired doorway leaks (ADR 0093 §3) ---------------------------------
@@ -39,6 +40,10 @@ module.exports = {
39
40
  // task 1003171 (C01): the count of open help requests addressed to a builder,
40
41
  // so the kernel's /me can put it in the needs ctx WITHOUT importing this module.
41
42
  openHelpRequestsFor: (...a) => helpRequests.countOpenForBuilder(...a),
43
+ // task 1003173 (C03): the count of open task recommendations waiting on a
44
+ // builder, exposed the same way and for the same reason — a recommendation
45
+ // nobody surfaces is merely stored.
46
+ openTaskRecommendationsFor: (...a) => taskRecommendations.countOpenForRecipient(...a),
42
47
  getTask: (...a) => db.getTask(...a),
43
48
  listVersions: (...a) => db.listVersions(...a),
44
49
  versionProgress: (...a) => db.versionProgress(...a),
@@ -0,0 +1,90 @@
1
+ -- lifecycle_005_task_recommendations.sql — "you should look at this": recommending
2
+ -- a task to someone, which is NOT handing it to them (task 1003173 / C03,
3
+ -- goal 1000075; criterion collab-pass-a-task, ADR 0187 §3).
4
+ --
5
+ -- THE DECISION THIS TABLE ENCODES. Passing a task RECOMMENDS it; it never
6
+ -- transfers the claim. The recipient still claims it themselves, so claim/release
7
+ -- stays the single path to holding a task and nobody acquires work without opting
8
+ -- in. There is deliberately NO transfer column, NO 'accepted' status that grants
9
+ -- anything, and no seam for a future transfer: the decision was recommend-only,
10
+ -- and an unused seam is a cost that later reads as an intention.
11
+ --
12
+ -- WHY THE REASON IS NOT NULLABLE. The criterion's own argument is that a
13
+ -- recommendation which leaves no trace is indistinguishable from a task quietly
14
+ -- changing hands. A reason the sender must write is what makes the ledger a
15
+ -- record of a judgement rather than a nudge — so it is NOT NULL with a non-blank
16
+ -- CHECK, the same shape lifecycle_help_requests gives what_is_stuck.
17
+ --
18
+ -- DECLINING COSTS NOTHING, and that is why 'declined' is just a status here and
19
+ -- nothing else in the schema references it. There is no counter, no cooldown, no
20
+ -- penalty column on either party, and no karma or credit link — a table that
21
+ -- recorded a decline anywhere but here would be the penalty the criterion forbids.
22
+ --
23
+ -- MODULE-OWNED and `lifecycle_`-prefixed, so fitness check 11 permits it: a NEW
24
+ -- table the lifecycle module owns outright, unlike `tasks`/`builders` which are
25
+ -- core tables it only references (ADR 0083 §Decision #5). Mirrors
26
+ -- lifecycle_004_help_requests, its sibling under this goal.
27
+
28
+ CREATE TABLE IF NOT EXISTS lifecycle_task_recommendations (
29
+ id bigserial PRIMARY KEY,
30
+
31
+ task_id bigint NOT NULL REFERENCES tasks(id),
32
+ recommended_by bigint NOT NULL REFERENCES builders(id),
33
+ recommended_to bigint NOT NULL REFERENCES builders(id),
34
+
35
+ -- Required, per the criterion. See the header: this column IS the trace.
36
+ reason text NOT NULL,
37
+
38
+ -- 'open' — sent, not yet acted on
39
+ -- 'declined' — the recipient said no. Terminal, and carries no consequence.
40
+ -- 'withdrawn' — the sender took it back. Also terminal.
41
+ --
42
+ -- There is NO 'accepted'. Accepting a recommendation is CLAIMING THE TASK, which
43
+ -- is the claims table's job and the recipient's own action; a status here saying
44
+ -- "accepted" would be a second, weaker record of holding work and would make this
45
+ -- table look like the place claims come from. Whether the recipient went on to
46
+ -- claim it is answered by joining tasks/claims, not by a column here.
47
+ status text NOT NULL DEFAULT 'open',
48
+
49
+ created_at timestamptz NOT NULL DEFAULT now(),
50
+ updated_at timestamptz NOT NULL DEFAULT now(),
51
+
52
+ CONSTRAINT lifecycle_task_recommendations_reason_chk
53
+ CHECK (length(btrim(reason)) > 0),
54
+
55
+ CONSTRAINT lifecycle_task_recommendations_status_chk
56
+ CHECK (status IN ('open', 'declined', 'withdrawn')),
57
+
58
+ -- Recommending a task to yourself is not a recommendation.
59
+ CONSTRAINT lifecycle_task_recommendations_not_self_chk
60
+ CHECK (recommended_by <> recommended_to)
61
+ );
62
+
63
+ -- One OPEN recommendation of a given task to a given person. A second send is the
64
+ -- same judgement repeated, and two identical open rows would make the recipient's
65
+ -- list lie about how many distinct things are waiting on them. Partial, so the
66
+ -- same task CAN be recommended again after a decline or a withdrawal — the
67
+ -- decision was that declining costs nothing, and a permanent unique key would
68
+ -- quietly make a decline final.
69
+ CREATE UNIQUE INDEX IF NOT EXISTS lifecycle_task_recommendations_open_uniq
70
+ ON lifecycle_task_recommendations (task_id, recommended_to) WHERE status = 'open';
71
+
72
+ -- The recipient's read: "what has been recommended to me". Partial — a settled
73
+ -- recommendation is history and never needs routing again (the 004 pattern).
74
+ CREATE INDEX IF NOT EXISTS lifecycle_task_recommendations_open_to_idx
75
+ ON lifecycle_task_recommendations (recommended_to) WHERE status = 'open';
76
+
77
+ -- The sender's read, and the audit read the criterion asks for: the recommendation
78
+ -- and its author are readable AFTERWARDS, so this one is not filtered by status.
79
+ CREATE INDEX IF NOT EXISTS lifecycle_task_recommendations_by_idx
80
+ ON lifecycle_task_recommendations (recommended_by, created_at DESC);
81
+
82
+ -- "Who recommended this task, and why" — the per-task history.
83
+ CREATE INDEX IF NOT EXISTS lifecycle_task_recommendations_task_idx
84
+ ON lifecycle_task_recommendations (task_id, created_at DESC);
85
+
86
+ COMMENT ON TABLE lifecycle_task_recommendations IS
87
+ 'A recommendation that someone look at a task (task 1003173). It NEVER transfers a claim: there is no accepted status and no transfer column, because claiming remains the recipient''s own action.';
88
+
89
+ INSERT INTO schema_migrations (version) VALUES ('lifecycle_005_task_recommendations')
90
+ ON CONFLICT (version) DO NOTHING;
@@ -19,6 +19,8 @@
19
19
  "analytics",
20
20
  "lifecycle",
21
21
  "help-requests",
22
+ "recommendations",
23
+ "closeness",
22
24
  "visuals"
23
25
  ],
24
26
  "pollers": [