@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.
- package/.bongos-core.json +64 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +20 -0
- package/clients/bongos-client/index.cjs +20 -0
- package/clients/bongos-client/index.d.ts +30 -0
- package/clients/bongos-client/index.mjs +20 -0
- package/docs/api/openapi.json +512 -3
- package/docs/api-reference.md +20 -2
- package/modules/builder-settings/builder-needs.js +29 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +20 -0
- package/modules/lifecycle/closeness-weights.json +64 -0
- package/modules/lifecycle/closeness.js +363 -0
- package/modules/lifecycle/lifecycle.js +5 -0
- package/modules/lifecycle/migrations/lifecycle_005_task_recommendations.sql +90 -0
- package/modules/lifecycle/module.json +2 -0
- package/modules/lifecycle/routes/closeness.js +118 -0
- package/modules/lifecycle/routes/recommendations.js +148 -0
- package/modules/lifecycle/task-recommendations.js +247 -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/src/bongos/routes/me.js +15 -0
- package/src/module-api.js +5 -1
- package/tests/closeness.mjs +324 -0
- package/tests/task_recommendations.mjs +297 -0
|
@@ -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;
|