@bongos/core 1.19.1015 → 1.19.1017
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 +159 -94
- package/.claude/skills/planning-session/SKILL.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -2
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0331-the-sky-is-the-ideators-home.md +12 -0
- package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
- package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +113 -0
- package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +201 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +60 -5
- package/docs/api-reference.md +8 -2
- package/docs/architecture.md +4 -1
- package/docs/copy-inventory.md +5 -3
- package/docs/copy-registry.json +22 -4
- package/docs/file-map.md +1 -2
- package/docs/module-api-changelog.md +5 -1
- package/migrations/core_253_idea_filed_via.sql +59 -0
- package/modules/agents/pollers/event-router.js +2 -0
- package/modules/discord/discord-bugs.js +2 -0
- package/modules/discord/discord-inbound.js +3 -0
- package/modules/hall-ui/public/people.html +11 -0
- package/modules/hall-ui/public/people.js +77 -11
- package/modules/hall-ui/public/people.states.json +1 -1
- package/modules/hall-ui/public/sky-data.js +22 -8
- package/modules/hall-ui/public/sky-draw.js +3 -2
- package/modules/hall-ui/public/sky-panel.js +1 -1
- package/modules/ideas/born-of.js +55 -0
- package/modules/ideas/inbox.js +23 -8
- package/modules/ideas/projection.js +48 -0
- package/modules/ideas/routes/inbox.js +20 -0
- package/modules/lifecycle/sky.js +25 -4
- package/modules/platform-identity/account-visibility.js +33 -2
- package/modules/platform-identity/activity-snapshots.js +47 -0
- package/modules/platform-identity/builder-search.js +153 -0
- package/modules/platform-identity/migrations/platform_identity_022_activity_snapshots.sql +46 -0
- package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +13 -0
- package/modules/platform-identity/routes/community-search.js +120 -0
- package/modules/platform-identity/routes/scouting.js +14 -7
- package/modules/platform-identity/scouting-name-only.js +47 -0
- package/modules/platform-identity/tests/activity-snapshots.mjs +68 -0
- package/modules/platform-identity/tests/platform-identity.mjs +21 -0
- package/modules/public-landing/public/projects-floor.states.json +9 -2
- package/modules/public-landing/public/projects.html +69 -4
- package/modules/ui-design/skills/impeccable/SKILL.md +1 -1
- package/modules/ui-design/styles/expedition/mock.html +16 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/capture.js +11 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +4 -1
- package/tests/activity_snapshots_db.mjs +97 -0
- package/tests/card_apply_ui.mjs +297 -0
- package/tests/community_search_route.mjs +385 -0
- package/tests/discord_bugs.mjs +1 -0
- package/tests/discord_inbound.mjs +1 -0
- package/tests/effective_visibility_predicate.mjs +49 -1
- package/tests/goal_map_page.mjs +14 -0
- package/tests/guild_namespace_reserved.mjs +76 -0
- package/tests/hall_sky.mjs +33 -16
- package/tests/idea_filed_via.mjs +185 -0
- package/tests/landing_page.mjs +8 -0
- package/tests/people_name_only.mjs +262 -0
- package/tests/profile_rollup_consent.mjs +5 -1
- package/tests/profile_route.mjs +23 -5
- package/tests/project_admin_console_model.mjs +105 -0
- package/tests/recruiter_sliver_boundary.mjs +3 -0
- package/tests/scouting_owner_access_and_reach.mjs +112 -2
- package/tests/sky_read.mjs +19 -2
- package/tests/ui_design_skills.mjs +9 -0
- package/tests/ui_design_styles.mjs +13 -0
- package/modules/public-landing/public/world/assets/clouds/cloud-island-large.png +0 -0
- package/modules/public-landing/public/world/assets/clouds/cloud-puff.png +0 -0
- package/modules/public-landing/public/world/assets/creatures/creature-whale-cargo.png +0 -0
- package/modules/public-landing/public/world/assets/creatures/creature-whale-glide.png +0 -0
- package/modules/public-landing/public/world/assets/plates/plate-clouds-near.png +0 -0
- package/modules/public-landing/public/world/assets/plates/scene-establishing.png +0 -0
|
@@ -21,10 +21,13 @@
|
|
|
21
21
|
// instance's version ids; the goal layout; and the thoughts — their state,
|
|
22
22
|
// their placement, their words.
|
|
23
23
|
//
|
|
24
|
-
// TWO LAYERS
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
24
|
+
// TWO LAYERS BECAME LEDGER FACTS IN PART 5 (task 1004233; the README's "must
|
|
25
|
+
// not ship" rows, shipped): the "claude layer" reads `filed_via` off each row of
|
|
26
|
+
// GET /sky/mine — cli, api and discord wait behind the pill, hall and planning
|
|
27
|
+
// always show, and a row from before the column (NULL) shows and says "filed
|
|
28
|
+
// before the ledger recorded where" — and every born-of mark reads `born` off
|
|
29
|
+
// GET /sky (which thoughts became goals or rings, from the ratify tables).
|
|
30
|
+
// Nothing here guesses either from text.
|
|
28
31
|
//
|
|
29
32
|
// ONE PAGE, TWO DOORS (part 4, task 1004232). /thinking and /goal-map are the
|
|
30
33
|
// same seven files over the same markup; the body's `data-sky-lens` says which
|
|
@@ -73,6 +76,15 @@
|
|
|
73
76
|
const STOP = new Set('a an the to of in on for and or is it this that with from by as at be are was not no we you your our into onto when what how why should could would can will every each all any some one two page let me without does did has have had its their them then than but so if'.split(' '));
|
|
74
77
|
function tokens(s) { return String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w)); }
|
|
75
78
|
const toMs = (iso) => iso ? new Date(iso).getTime() : 0;
|
|
79
|
+
// Where an idea was filed from (task 1004233): the five doors the ledger
|
|
80
|
+
// records, which of them a SESSION files through (the "claude layer" hides
|
|
81
|
+
// these until the pill is pressed), and the words the panel says for each.
|
|
82
|
+
const SESSION_VIAS = Object.freeze(['cli', 'api', 'discord']);
|
|
83
|
+
const VIA_WORDS = { hall: 'from the hall', cli: 'from the command line', discord: 'from Discord', planning: 'in a planning session', api: 'through the API' };
|
|
84
|
+
function viaWord(v) { return v == null ? 'filed before the ledger recorded where' : (VIA_WORDS[v] || 'through ' + v); }
|
|
85
|
+
// bornFromSky — GET /sky's `born` as string-keyed, string-valued maps; absent
|
|
86
|
+
// or half-shaped is empty (no mark is drawn on a guess).
|
|
87
|
+
function bornFromSky(b) { const m = (o) => { const out = {}; if (o && typeof o === 'object') for (const [k, v] of Object.entries(o)) if (v != null) out[String(k)] = String(v); return out; }; return { goals: m(b && b.goals), criteria: m(b && b.criteria) }; }
|
|
76
88
|
// The hall's own doors — the prototype linked to a fixed hall origin; the
|
|
77
89
|
// page links to the hall it is served from (apex /builders or the subdomain).
|
|
78
90
|
const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
|
|
@@ -125,9 +137,10 @@
|
|
|
125
137
|
size: i.size || null,
|
|
126
138
|
state: st ? { ...st, wellGoalId: st.wellGoalId != null ? String(st.wellGoalId) : null } : null,
|
|
127
139
|
mergedInto: i.merged_into_idea_id != null ? String(i.merged_into_idea_id) : null,
|
|
128
|
-
// filed_via
|
|
129
|
-
//
|
|
130
|
-
|
|
140
|
+
// filed_via (part 5): a session's door puts the thought behind the claude
|
|
141
|
+
// layer; a person's door, and a row from before the column (NULL — the
|
|
142
|
+
// owner: historic ideas SHOW by default), do not.
|
|
143
|
+
filedVia: i.filed_via ?? null, origin: SESSION_VIAS.includes(i.filed_via) ? 'claude' : 'human',
|
|
131
144
|
};
|
|
132
145
|
}
|
|
133
146
|
|
|
@@ -184,6 +197,7 @@
|
|
|
184
197
|
// Called once, after the reads; everything it derives lands on S.
|
|
185
198
|
function buildWorld({ sky, mine, versions }) {
|
|
186
199
|
S.ASPECT = clamp(innerWidth / Math.max(1, innerHeight), 1, 1.8);
|
|
200
|
+
S.BORN = bornFromSky(sky && sky.born);
|
|
187
201
|
const BORN = S.BORN;
|
|
188
202
|
const goals = ((sky && sky.goals) || []).map(goalFromSky).map((g) => ({ ...g, bornOf: BORN.goals[g.id] || null, born: toMs(g.createdAt) }));
|
|
189
203
|
const goalById = new Map(goals.map((g) => [g.id, g]));
|
|
@@ -299,7 +313,7 @@
|
|
|
299
313
|
|
|
300
314
|
Object.assign(SKY, {
|
|
301
315
|
S, $, TAU, GOLDEN, reduce, NOW, hash, rng, clamp, lerp, smooth, ease, esc, fmtDate, fmtDay, ageDays, plain, short, store, tokens, toMs, hallBase, API, api,
|
|
302
|
-
OBJ, objects, goalFromSky, ideaFromMine, bandsFor, wellRadius, ringR, taskAt, buildWorld, edgesOf,
|
|
316
|
+
OBJ, objects, SESSION_VIAS, viaWord, bornFromSky, goalFromSky, ideaFromMine, bandsFor, wellRadius, ringR, taskAt, buildWorld, edgesOf,
|
|
303
317
|
applyRehearsal, inLayer, shown, diameter, positionWord, brightnessWord, reach, became, temp, placeIdeas, touched, load,
|
|
304
318
|
});
|
|
305
319
|
})();
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
// thicker with credits), bodies are tasks past the zoom threshold and dust
|
|
8
8
|
// below it, and a thought is one of the five objects — its SIZE the credits
|
|
9
9
|
// its descent was paid (never smaller for being dead), its BRIGHTNESS how
|
|
10
|
-
// much of that work still stands. Born-of marks
|
|
11
|
-
//
|
|
10
|
+
// much of that work still stands. Born-of marks read GET /sky's `born` and the
|
|
11
|
+
// "filed by a session" accent reads `filed_via` — ledger facts since part 5
|
|
12
|
+
// (task 1004233), never a guess from text.
|
|
12
13
|
|
|
13
14
|
(() => {
|
|
14
15
|
'use strict';
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
${i.size ? fact('of which', esc(sizeWords(i))) : ''}
|
|
42
42
|
${bright ? fact('brightness — still alive', `<span class="bar" style="width:${Math.max(4, Math.round(i.lit * 70))}px;${i.lit > 0 ? '' : 'background:rgba(255,255,255,.2)'}"></span>${esc(bright)}`) : ''}
|
|
43
43
|
${pos ? fact('position', esc(pos)) : ''}
|
|
44
|
-
${fact('filed', esc(fmtDate(i.capturedAt)) + (i.kind ? ` · ${esc(i.kind)}` : '') + (i.grade === 'full' ? ' · full idea' : '') + (i.origin === 'claude' ? ' · by a session, under your name' : ''))}
|
|
44
|
+
${fact('filed', esc(fmtDate(i.capturedAt)) + (i.kind ? ` · ${esc(i.kind)}` : '') + (i.grade === 'full' ? ' · full idea' : '') + ' · ' + esc(SKY.viaWord(i.filedVia)) + (i.origin === 'claude' ? ' · by a session, under your name' : ''))}
|
|
45
45
|
</div>
|
|
46
46
|
${i.fields && FIELDS.length ? `<div><span class="lbl">the five answers · ${SKY.completeness(i.fields)} of ${FIELDS.length} above the floor</span><ul class="orbit-list">${FIELDS.map((f) => `<li><span class="st">${esc(f.key.replace('_', ' '))}</span><span>${esc(i.fields[f.key] || '— skipped')}</span></li>`).join('')}</ul></div>` : ''}
|
|
47
47
|
${i.lineage.length ? `<div><span class="lbl">the descent — everything that fell out of it</span><ul class="orbit-list">${[...i.lineage, ...i.descentTasks].map((t) => `<li><span class="st${t.status === 'active' ? ' live' : ''}">${esc(t.status)}</span><span>${esc(t.title)}${t.goalId && t.goalId !== i.well && goalById.get(t.goalId) ? ` <span class="lbl">in ${esc(short(goalById.get(t.goalId).title))}</span>` : ''}</span></li>`).join('')}</ul></div>` : ''}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/ideas/born-of.js — WHICH THOUGHTS BECAME THE STRUCTURE (task 1004233,
|
|
4
|
+
// sky part 5 of 5; goal 1000095; ADR 0331 §4).
|
|
5
|
+
//
|
|
6
|
+
// The sky draws a goal born of a thought with a nucleus ring, a ring (a
|
|
7
|
+
// criterion) born of one with an accent notch, and places the thought itself at
|
|
8
|
+
// the well's core or on its ring. The prototype found "born of" by scanning goal
|
|
9
|
+
// and criterion text for idea ids; this read answers it from the two tables the
|
|
10
|
+
// ratify paths already write, so it is a ledger fact and never a guess:
|
|
11
|
+
// goals — ideas_goal_ratifications (ratify.js ratifyIdeaAsGoal): one row
|
|
12
|
+
// per idea → goal
|
|
13
|
+
// criteria — criterion_proposal_audit (ratify.js ratifyCriterionProposal):
|
|
14
|
+
// one row per idea → criterion
|
|
15
|
+
// The FIRST ratification names the thought (ordered by ratified_at, then id);
|
|
16
|
+
// ids come back as strings keyed by the thing born, so a page looks a well or a
|
|
17
|
+
// ring up directly:
|
|
18
|
+
// { goals: { [goal_id]: idea_id }, criteria: { [criterion_id]: idea_id } }
|
|
19
|
+
//
|
|
20
|
+
// Served through the `ideas.bornOf` PORT (registered beside `ideas.capture` in
|
|
21
|
+
// routes/inbox.js) so the lifecycle module's GET /sky can fold it in as `born`
|
|
22
|
+
// without importing this module or reading an ideas_-prefixed table itself
|
|
23
|
+
// (ADR 0083's one-way rule). An instance without the port gets an empty map,
|
|
24
|
+
// which the pages draw as "nothing is known", not "nothing was born".
|
|
25
|
+
|
|
26
|
+
const api = require('../../src/module-api');
|
|
27
|
+
const { pool } = api;
|
|
28
|
+
|
|
29
|
+
function emptyBorn() { return { goals: {}, criteria: {} }; }
|
|
30
|
+
|
|
31
|
+
// readBornOf — the two reads, folded first-wins into plain objects keyed by the
|
|
32
|
+
// born thing's id. deps.pool honours the test seam.
|
|
33
|
+
async function readBornOf(deps = {}) {
|
|
34
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
35
|
+
const [goals, criteria] = await Promise.all([
|
|
36
|
+
activePool.query(
|
|
37
|
+
`SELECT goal_id::text AS goal_id, idea_id::text AS idea_id
|
|
38
|
+
FROM ideas_goal_ratifications
|
|
39
|
+
ORDER BY ratified_at, id`
|
|
40
|
+
),
|
|
41
|
+
activePool.query(
|
|
42
|
+
`SELECT criterion_id::text AS criterion_id, idea_id::text AS idea_id
|
|
43
|
+
FROM criterion_proposal_audit
|
|
44
|
+
ORDER BY ratified_at, id`
|
|
45
|
+
),
|
|
46
|
+
]);
|
|
47
|
+
const firstWins = (rows, key) => {
|
|
48
|
+
const m = new Map();
|
|
49
|
+
for (const r of rows) if (r && r[key] != null && r.idea_id != null && !m.has(String(r[key]))) m.set(String(r[key]), String(r.idea_id));
|
|
50
|
+
return Object.fromEntries(m);
|
|
51
|
+
};
|
|
52
|
+
return { goals: firstWins(goals.rows, 'goal_id'), criteria: firstWins(criteria.rows, 'criterion_id') };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { readBornOf, emptyBorn };
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -32,6 +32,7 @@ const {
|
|
|
32
32
|
IDEA_COLS, IDEA_COLS_Q, GRADE_COLS_Q, IDEA_JOIN_GRADE, ungradedProjection,
|
|
33
33
|
DEFAULT_GRADE, VALID_GRADES, DEFAULT_TEMPLATE_KEY, TEMPLATE_KEY_MAX, FIELD_VALUES_MAX,
|
|
34
34
|
VALID_RECOMMENDATIONS, isValidRecommendation, normalizeRecommendation,
|
|
35
|
+
FILED_VIA_VALUES, isValidFiledVia, filedViaForSource, resolveFiledVia,
|
|
35
36
|
} = require('./projection');
|
|
36
37
|
// The carved domains. Each is re-exported at the bottom of this file, so every
|
|
37
38
|
// existing caller of `require('./inbox')` keeps working unchanged — the same
|
|
@@ -170,6 +171,13 @@ async function captureIdea({
|
|
|
170
171
|
// anything). Ignored when `goalId` is already set, so every existing caller
|
|
171
172
|
// is untouched.
|
|
172
173
|
suggestedGoalId = null,
|
|
174
|
+
// task 1004233 (sky part 5): WHICH DOOR the idea came through, as a ledger
|
|
175
|
+
// fact on the row — hall | cli | discord | planning | api (projection.js
|
|
176
|
+
// FILED_VIA_VALUES). The route reads it off the session; a port caller (the
|
|
177
|
+
// Discord bot, an agent poller) names its own. 'api' is the catch-all for a
|
|
178
|
+
// program that says nothing: a NEW row always records a door — NULL is
|
|
179
|
+
// reserved for rows older than the column and is never written here.
|
|
180
|
+
filedVia = 'api',
|
|
173
181
|
// WHICH DOOR this filing came through; absent is UNTRUSTED and stays an inbox
|
|
174
182
|
// row. Read by one rule — see routing.HOMELESS_ROUTING_VECTORS (ADR 0262).
|
|
175
183
|
vector = null,
|
|
@@ -187,6 +195,9 @@ async function captureIdea({
|
|
|
187
195
|
) {
|
|
188
196
|
throw { code: 'BAD_PRIORITY', priority: suggestedPriority };
|
|
189
197
|
}
|
|
198
|
+
if (!isValidFiledVia(filedVia)) {
|
|
199
|
+
throw { code: 'BAD_FILED_VIA', filedVia, allowed: [...FILED_VIA_VALUES] };
|
|
200
|
+
}
|
|
190
201
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
191
202
|
|
|
192
203
|
// The decision, before any write. Pure — see ./routing.js.
|
|
@@ -225,7 +236,7 @@ async function captureIdea({
|
|
|
225
236
|
if (landing.route) {
|
|
226
237
|
return routeIdeaToGoal({
|
|
227
238
|
title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority,
|
|
228
|
-
capturedBy, goalId, landing, filerRank, touches,
|
|
239
|
+
capturedBy, goalId, landing, filerRank, touches, filedVia,
|
|
229
240
|
}, activePool);
|
|
230
241
|
}
|
|
231
242
|
|
|
@@ -236,12 +247,12 @@ async function captureIdea({
|
|
|
236
247
|
const { rows } = await activePool.query(
|
|
237
248
|
`INSERT INTO idea_inbox
|
|
238
249
|
(title, body_md, kind, suggested_version, suggested_priority, captured_by, status,
|
|
239
|
-
suggested_goal_id)
|
|
250
|
+
suggested_goal_id, filed_via)
|
|
240
251
|
VALUES ($1, $2, $3, $4, $5, $6, 'open',
|
|
241
|
-
$7)
|
|
252
|
+
$7, $8)
|
|
242
253
|
RETURNING ${IDEA_COLS}`,
|
|
243
254
|
[title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy,
|
|
244
|
-
goalId ?? suggestedGoalId ?? null]
|
|
255
|
+
goalId ?? suggestedGoalId ?? null, filedVia]
|
|
245
256
|
);
|
|
246
257
|
// A brand-new idea has no ideas_grades row, so it is quick by definition —
|
|
247
258
|
// no join needed to know that. Grading it (R143's Full Idea capture) is a
|
|
@@ -266,7 +277,7 @@ async function captureIdea({
|
|
|
266
277
|
// the same two-step ratifyCriterionProposal takes.
|
|
267
278
|
async function routeIdeaToGoal({
|
|
268
279
|
title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority,
|
|
269
|
-
capturedBy, goalId, landing, filerRank = null, touches = null,
|
|
280
|
+
capturedBy, goalId, landing, filerRank = null, touches = null, filedVia = 'api',
|
|
270
281
|
}, activePool) {
|
|
271
282
|
const client = await activePool.connect();
|
|
272
283
|
try {
|
|
@@ -326,12 +337,12 @@ async function routeIdeaToGoal({
|
|
|
326
337
|
const { rows: ideaRows } = await client.query(
|
|
327
338
|
`INSERT INTO idea_inbox
|
|
328
339
|
(title, body_md, kind, suggested_version, suggested_priority, captured_by, status,
|
|
329
|
-
suggested_goal_id, reviewed_at)
|
|
340
|
+
suggested_goal_id, reviewed_at, filed_via)
|
|
330
341
|
VALUES ($1, $2, $3, $4, $5, $6, 'promoted',
|
|
331
|
-
$7, now())
|
|
342
|
+
$7, now(), $8)
|
|
332
343
|
RETURNING ${IDEA_COLS}`,
|
|
333
344
|
[title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy,
|
|
334
|
-
effectiveGoalId]
|
|
345
|
+
effectiveGoalId, filedVia]
|
|
335
346
|
);
|
|
336
347
|
const idea = ideaRows[0];
|
|
337
348
|
|
|
@@ -802,6 +813,10 @@ module.exports = {
|
|
|
802
813
|
// the same place the SQL CHECK mirrors
|
|
803
814
|
VALID_RECOMMENDATIONS,
|
|
804
815
|
isValidRecommendation,
|
|
816
|
+
FILED_VIA_VALUES,
|
|
817
|
+
isValidFiledVia,
|
|
818
|
+
filedViaForSource,
|
|
819
|
+
resolveFiledVia,
|
|
805
820
|
normalizeRecommendation,
|
|
806
821
|
// skill-friction marker + rank visibility gate (V3.R70 / #289; the kind
|
|
807
822
|
// itself landed in task 1003105 — both markers are live, see above)
|
|
@@ -25,6 +25,11 @@ const IDEA_COL_NAMES = [
|
|
|
25
25
|
'id', 'title', 'body_md', 'kind', 'suggested_version', 'suggested_priority',
|
|
26
26
|
'captured_at', 'captured_by', 'status', 'promoted_to_task_id', 'reviewed_at',
|
|
27
27
|
'merged_into_idea_id', 'triage_note',
|
|
28
|
+
// core_253_idea_filed_via (task 1004233, sky part 5): WHICH DOOR the idea came through
|
|
29
|
+
// (hall | cli | discord | planning | api), or NULL for a row older than the
|
|
30
|
+
// column. On the shared list so every read carries it — GET /sky/mine is the
|
|
31
|
+
// trail's own projection and the sky's "claude layer" reads it off the row.
|
|
32
|
+
'filed_via',
|
|
28
33
|
// core_222 (task 1003060, BV1.R02): the routing HINT — "this probably belongs
|
|
29
34
|
// to goal N" — on a row that did NOT convert. Added to the shared list because
|
|
30
35
|
// R02 shipped the column and nothing read it: /idea-triage (R13) cannot walk
|
|
@@ -34,6 +39,44 @@ const IDEA_COL_NAMES = [
|
|
|
34
39
|
'suggested_goal_id',
|
|
35
40
|
];
|
|
36
41
|
const IDEA_COLS = IDEA_COL_NAMES.join(', ');
|
|
42
|
+
|
|
43
|
+
// -------------------------------------------------------------------------
|
|
44
|
+
// Where an idea was filed from (task 1004233 / sky part 5, goal 1000095; ADR 0331 §4)
|
|
45
|
+
//
|
|
46
|
+
// The closed set idea_inbox_filed_via_known mirrors (core_253_idea_filed_via). NULL — a row
|
|
47
|
+
// older than the column — is a sixth, honest answer the sky speaks as "filed
|
|
48
|
+
// before the ledger recorded where"; it is never backfilled from text, which
|
|
49
|
+
// would put the prototype's guess into the ledger.
|
|
50
|
+
//
|
|
51
|
+
// THE DOOR IS READ OFF THE SESSION, not claimed in the body. A web cookie is the
|
|
52
|
+
// hall (the ideas form, the sky's composer), a device-flow token is the CLI
|
|
53
|
+
// (capture.js, spark.js), anything else (a box token, a program on the port) is
|
|
54
|
+
// the API — so a filer cannot say they came through a door they did not. The one
|
|
55
|
+
// value a body may ADD is 'planning': a planning session's capture is a CLI
|
|
56
|
+
// filing no session can tell apart, so capture.js --via planning names it. The
|
|
57
|
+
// Discord bot names 'discord' itself: it calls the port, not the route.
|
|
58
|
+
const FILED_VIA_VALUES = Object.freeze(['hall', 'cli', 'discord', 'planning', 'api']);
|
|
59
|
+
const isValidFiledVia = (v) => typeof v === 'string' && FILED_VIA_VALUES.includes(v);
|
|
60
|
+
function filedViaForSource(source) {
|
|
61
|
+
return source === 'web' ? 'hall' : source === 'cli' ? 'cli' : 'api';
|
|
62
|
+
}
|
|
63
|
+
// resolveFiledVia — the session's door, unless the body names 'planning' (or
|
|
64
|
+
// repeats the session's own door, which is harmless). Anything else is refused
|
|
65
|
+
// with the reason and the one value that may be named.
|
|
66
|
+
function resolveFiledVia(source, claimed) {
|
|
67
|
+
const own = filedViaForSource(source);
|
|
68
|
+
if (claimed === undefined || claimed === null || claimed === own) return { ok: true, value: own };
|
|
69
|
+
if (claimed === 'planning') return { ok: true, value: 'planning' };
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
detail: {
|
|
73
|
+
filed_via: claimed, session_door: own, allowed: ['planning', own],
|
|
74
|
+
reason: isValidFiledVia(claimed)
|
|
75
|
+
? `filed_via is read off your session (${own}); the only door a filing may add is 'planning'.`
|
|
76
|
+
: `filed_via must be one of ${FILED_VIA_VALUES.join(', ')} — and only 'planning' may be named by a filing.`,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
37
80
|
const IDEA_COLS_Q = IDEA_COL_NAMES.map((c) => `i.${c}`).join(', ');
|
|
38
81
|
|
|
39
82
|
// -------------------------------------------------------------------------
|
|
@@ -153,6 +196,11 @@ module.exports = {
|
|
|
153
196
|
VALID_RECOMMENDATIONS,
|
|
154
197
|
isValidRecommendation,
|
|
155
198
|
normalizeRecommendation,
|
|
199
|
+
FILED_VIA_VALUES,
|
|
200
|
+
isValidFiledVia,
|
|
201
|
+
filedViaForSource,
|
|
202
|
+
resolveFiledVia,
|
|
203
|
+
IDEA_COL_NAMES,
|
|
156
204
|
GRADE_COLS_Q,
|
|
157
205
|
IDEA_JOIN_GRADE,
|
|
158
206
|
ungradedProjection,
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
const express = require('express');
|
|
36
36
|
const api = require('../../../src/module-api');
|
|
37
37
|
const inbox = require('../inbox');
|
|
38
|
+
const bornOf = require('../born-of');
|
|
38
39
|
// The trail read to the FULL depth of the descent (task 1004089, BV2.R09). Its
|
|
39
40
|
// own file because inbox.js is AT the size ratchet and because a task->task
|
|
40
41
|
// walk is a different relation from the idea->task trail.
|
|
@@ -95,6 +96,12 @@ function registerSeams() {
|
|
|
95
96
|
if (!api.hasProvider('ideas.capture')) {
|
|
96
97
|
api.registerProvider('ideas.capture', inbox.captureIdea);
|
|
97
98
|
}
|
|
99
|
+
// task 1004233 (sky part 5): which thoughts became goals or rings, for the
|
|
100
|
+
// lifecycle module's GET /sky to fold in as `born` — through the port, so
|
|
101
|
+
// lifecycle never reads an ideas_ table itself (./born-of.js).
|
|
102
|
+
if (!api.hasProvider('ideas.bornOf')) {
|
|
103
|
+
api.registerProvider('ideas.bornOf', bornOf.readBornOf);
|
|
104
|
+
}
|
|
98
105
|
if (!api.hasProvider('ideas.descent')) {
|
|
99
106
|
api.registerProvider('ideas.descent', {
|
|
100
107
|
ancestorsOfTask: descent.ancestorsOfTask,
|
|
@@ -176,8 +183,19 @@ module.exports = function buildInboxRouter() {
|
|
|
176
183
|
// turned out: 'goal' or 'criterion'. OPTIONAL, permanently (see the
|
|
177
184
|
// refusal below and ideas_009's header).
|
|
178
185
|
recommendation: { type: 'string', maxLength: 16 },
|
|
186
|
+
// task 1004233 (sky part 5): WHICH DOOR. Read off the session — never a
|
|
187
|
+
// claim — except 'planning' (capture.js --via planning); see below.
|
|
188
|
+
filed_via: { type: 'string', maxLength: 16 },
|
|
179
189
|
})) return;
|
|
180
190
|
const body = req.body || {};
|
|
191
|
+
// task 1004233 (sky part 5): the door is a FACT read off the session that
|
|
192
|
+
// made the request (projection.js resolveFiledVia: a web cookie is the hall,
|
|
193
|
+
// a device-flow token is the CLI, anything else the API). The body may add
|
|
194
|
+
// only 'planning' — the one door no session can tell from a CLI capture —
|
|
195
|
+
// and any other claim is refused, so a filer cannot say they came through a
|
|
196
|
+
// door they did not.
|
|
197
|
+
const door = inbox.resolveFiledVia(req.gdsSession && req.gdsSession.source, body.filed_via);
|
|
198
|
+
if (!door.ok) return res.fail('bad_filed_via', 400, door.detail);
|
|
181
199
|
if (body.kind === 'criterion-proposal') {
|
|
182
200
|
// FAIL CLOSED (task 1003197 (D)). This was inline and read
|
|
183
201
|
// `if (rank && rank !== 'metic' && …)`, so an ABSENT rank ADMITTED. The
|
|
@@ -253,6 +271,7 @@ module.exports = function buildInboxRouter() {
|
|
|
253
271
|
goalId: body.goal_id ?? null,
|
|
254
272
|
filerRank: req.builder?.rank ?? null,
|
|
255
273
|
grade,
|
|
274
|
+
filedVia: door.value,
|
|
256
275
|
// task 1003691 (ADR 0262): the vector, hardcoded — NEVER read from the
|
|
257
276
|
// body. This route is the authenticated API door, so a bug filed here
|
|
258
277
|
// with no goal routes to the version's maintenance goal instead of
|
|
@@ -367,6 +386,7 @@ module.exports = function buildInboxRouter() {
|
|
|
367
386
|
err.code === 'TITLE_REQUIRED' ? 400 :
|
|
368
387
|
err.code === 'BAD_KIND' ? 400 :
|
|
369
388
|
err.code === 'BAD_PRIORITY' ? 400 :
|
|
389
|
+
err.code === 'BAD_FILED_VIA' ? 400 :
|
|
370
390
|
// task 1003065 (BV1.R01) — the routing refusals. A named goal that is
|
|
371
391
|
// not a live home is the FILER'S mistake to fix (pick another goal, or
|
|
372
392
|
// omit it and file to the inbox), so these are 4xx with the offending
|
package/modules/lifecycle/sky.js
CHANGED
|
@@ -137,12 +137,17 @@ function foldCriteria(rows = []) {
|
|
|
137
137
|
// satisfied, pending_review, task_ids }], tasks: [{ id, title, status,
|
|
138
138
|
// credits_reward, created_on, idea_id }] }]
|
|
139
139
|
// edges: [{ from, to, count }] — `from` waits on `to`, the graph's own
|
|
140
|
+
// born: { goals: { goal_id: idea_id }, criteria: { criterion_id: idea_id } }
|
|
141
|
+
// — which thoughts became the structure (task
|
|
142
|
+
// 1004233): the ideas module's own read through the
|
|
143
|
+
// `ideas.bornOf` port, ids as strings; an absent
|
|
144
|
+
// port is an EMPTY map, never a guess
|
|
140
145
|
// counts: { goals, edges, criteria, tasks }
|
|
141
146
|
//
|
|
142
147
|
// A criterion or a task whose goal is not among the wells is DROPPED — the wells
|
|
143
148
|
// are the graph's nodes (goals carrying at least one task, in the version asked
|
|
144
149
|
// for), and a ring drawn around no well is a line pointing at nothing.
|
|
145
|
-
function shapeSky({ goals = [], edges = [], extras = [], criteria = [], tasks = [] } = {}, categoryById = new Map()) {
|
|
150
|
+
function shapeSky({ goals = [], edges = [], extras = [], criteria = [], tasks = [], born = null } = {}, categoryById = new Map()) {
|
|
146
151
|
const graph = goalGraph.shapeGraph({ goals, edges }, categoryById);
|
|
147
152
|
const visibleById = new Map(goals.map((g) => [String(g.goal_id), g.visible !== false]));
|
|
148
153
|
const extraById = new Map(extras.map((x) => [String(x.goal_id), x]));
|
|
@@ -189,18 +194,34 @@ function shapeSky({ goals = [], edges = [], extras = [], criteria = [], tasks =
|
|
|
189
194
|
return {
|
|
190
195
|
goals: out,
|
|
191
196
|
edges: graph.edges,
|
|
197
|
+
born: normalizeBorn(born),
|
|
192
198
|
counts: { goals: out.length, edges: graph.edges.length, criteria: criteriaCount, tasks: taskCount },
|
|
193
199
|
};
|
|
194
200
|
}
|
|
195
201
|
|
|
202
|
+
// normalizeBorn — the port's answer as string-keyed, string-valued maps; a null,
|
|
203
|
+
// a throw or a half-shaped answer is the empty map (the page draws no mark).
|
|
204
|
+
function normalizeBorn(born) {
|
|
205
|
+
const strMap = (o) => {
|
|
206
|
+
const out = {};
|
|
207
|
+
if (o && typeof o === 'object') for (const [k, v] of Object.entries(o)) if (v != null) out[String(k)] = String(v);
|
|
208
|
+
return out;
|
|
209
|
+
};
|
|
210
|
+
return { goals: strMap(born && born.goals), criteria: strMap(born && born.criteria) };
|
|
211
|
+
}
|
|
212
|
+
|
|
196
213
|
// sky — read + shape. `categoryIndex` is injectable so the whole path runs in a
|
|
197
214
|
// test with a fake pool and no category table (the goal-graph.js precedent).
|
|
198
215
|
async function sky(opts = {}, deps = {}) {
|
|
199
|
-
|
|
216
|
+
// task 1004233: born of a thought is the IDEAS module's read, reached through
|
|
217
|
+
// its port — one of the two reads is enough to fail quietly into an empty map.
|
|
218
|
+
const readBorn = 'readBornOf' in deps ? deps.readBornOf : (api.resolveOptional ? api.resolveOptional('ideas.bornOf') : null);
|
|
219
|
+
const [rows, byId, born] = await Promise.all([
|
|
200
220
|
skyRows(opts, deps),
|
|
201
221
|
(deps.categoryIndex || goalCategory.categoryIndex)(deps),
|
|
222
|
+
typeof readBorn === 'function' ? readBorn().catch(() => null) : null,
|
|
202
223
|
]);
|
|
203
|
-
return shapeSky(rows, byId);
|
|
224
|
+
return shapeSky({ ...rows, born }, byId);
|
|
204
225
|
}
|
|
205
226
|
|
|
206
227
|
// skyCache — the 60-second memo, keyed by what decides what the viewer may see.
|
|
@@ -258,4 +279,4 @@ function skyHandler({ read = sky, log = console, cache = skyCache() } = {}) {
|
|
|
258
279
|
};
|
|
259
280
|
}
|
|
260
281
|
|
|
261
|
-
module.exports = { sky, skyRows, shapeSky, foldCriteria, skyCache, skyHandler, SKY_CACHE_TTL_MS };
|
|
282
|
+
module.exports = { sky, skyRows, shapeSky, foldCriteria, normalizeBorn, skyCache, skyHandler, SKY_CACHE_TTL_MS };
|
|
@@ -32,8 +32,13 @@
|
|
|
32
32
|
// recruiterReachSql / isRecruiterDiscoverable REACH — the COALESCE, privacy
|
|
33
33
|
// spec D1. A reach predicate only: it never buys addressability.
|
|
34
34
|
// scoutingListableSql THE DIRECTORY — numbers AND reach,
|
|
35
|
-
// the
|
|
36
|
-
// 1002290). Composed, never restated.
|
|
35
|
+
// the predicate the recruiting directory's ranked tier joins (ADR 0210,
|
|
36
|
+
// task 1002290). Composed, never restated.
|
|
37
|
+
// scoutingNameOnlySql THE DIRECTORY'S NAME-ONLY TIER —
|
|
38
|
+
// existence AND hidden AND reach (ADR 0334, task 1002291; privacy spec D2:
|
|
39
|
+
// a hidden builder is "listed name-only (if not opted out)"). The same
|
|
40
|
+
// predicate as the one above with the hide term flipped, so the two tiers
|
|
41
|
+
// partition the directory: hide decides a row's SHAPE, reach its presence.
|
|
37
42
|
// recruiterSliverVisibleSql / isRecruiterSliverVisible THE PRE-ACCEPT
|
|
38
43
|
// SLIVER — consent AND reach, WITHOUT the existence axis (ADR 0215, task
|
|
39
44
|
// 1002968; privacy spec D3). The one predicate that deliberately does not
|
|
@@ -205,6 +210,31 @@ function scoutingListableSql(alias) {
|
|
|
205
210
|
return `(${rollupVisibleSql(alias)} AND ${recruiterReachSql(alias)})`;
|
|
206
211
|
}
|
|
207
212
|
|
|
213
|
+
// scoutingNameOnlySql — WHO the recruiting directory lists by NAME ALONE: a
|
|
214
|
+
// publicly visible, reachable account that hid its cross-project record (ADR
|
|
215
|
+
// 0334, task 1002291). Privacy spec D2's recruiter column lists that account
|
|
216
|
+
// "name-only (if not opted out)"; before this the directory dropped it outright,
|
|
217
|
+
// reading hide as a second opt-out while Settings told the builder recruiters
|
|
218
|
+
// would see their name and picture.
|
|
219
|
+
//
|
|
220
|
+
// It is scoutingListableSql with the hide term FLIPPED and nothing else, shaped
|
|
221
|
+
// identically so the two tiers cannot overlap or leave a gap
|
|
222
|
+
// (tests/effective_visibility_predicate.mjs holds that). It keeps the EXISTENCE
|
|
223
|
+
// floor, so a private account is never listed even by name — that is the D3
|
|
224
|
+
// sliver's subject, at the archon floor (ADR 0215/0217).
|
|
225
|
+
//
|
|
226
|
+
// DANGEROUS OUTSIDE ITS ONE READER. It admits hidden accounts by design, so a
|
|
227
|
+
// read that joined it and returned a number would hand out exactly the record
|
|
228
|
+
// hide_stats withholds. scouting-name-only.js is its only reader and selects
|
|
229
|
+
// three identity columns; the guard pins both.
|
|
230
|
+
function scoutingNameOnlySql(alias) {
|
|
231
|
+
if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
|
|
232
|
+
throw new Error('scoutingNameOnlySql: alias must be a bare SQL identifier');
|
|
233
|
+
}
|
|
234
|
+
const q = alias ? `${alias}.` : '';
|
|
235
|
+
return `((${accountVisibleSql(alias)} AND ${q}hide_stats) AND ${recruiterReachSql(alias)})`;
|
|
236
|
+
}
|
|
237
|
+
|
|
208
238
|
// recruiterSliverVisibleSql — THE predicate the PRE-ACCEPT recruiter sliver
|
|
209
239
|
// joins: the CONSENT floor AND the reach axis (ADR 0215, privacy spec D3; task
|
|
210
240
|
// 1002968). modules/platform-identity/recruiter-sliver.js is its one reader.
|
|
@@ -256,6 +286,7 @@ module.exports = {
|
|
|
256
286
|
isRecruiterDiscoverable,
|
|
257
287
|
recruiterReachSql,
|
|
258
288
|
scoutingListableSql,
|
|
289
|
+
scoutingNameOnlySql,
|
|
259
290
|
recruiterSliverVisibleSql,
|
|
260
291
|
isRecruiterSliverVisible,
|
|
261
292
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// modules/platform-identity/activity-snapshots.js — the windowed data plane's
|
|
2
|
+
// append-only history (community spec D2, task 1002977): one row per rollup
|
|
3
|
+
// report WHOSE TOTALS CHANGED, so a leaderboard window (7d/30d/...) can compute
|
|
4
|
+
// "latest minus the last snapshot at-or-before the window start" without a core
|
|
5
|
+
// change (option B) or an unbounded event feed (option C) — both rejected for V1.
|
|
6
|
+
//
|
|
7
|
+
// Reporting-time semantics, not earning-time: a builder who signs in fortnightly
|
|
8
|
+
// lands two weeks of credits in the sign-in week. Honest, accepted V1 limitation
|
|
9
|
+
// (see the spec); self-correcting on the builder's next report.
|
|
10
|
+
//
|
|
11
|
+
// Compaction (daily granularity after 90 days) is a later, volume-triggered task
|
|
12
|
+
// — nothing here prunes. A disconnected/re-registered project's snapshots are
|
|
13
|
+
// likewise not purged here — platform_identity_builder_project_activity (the
|
|
14
|
+
// table this mirrors) has the identical gap today; the future leaderboard reader
|
|
15
|
+
// is where consent (rollupVisibleSql) is enforced, per that table's own precedent.
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const { pool: defaultPool } = require('../../src/module-api');
|
|
19
|
+
|
|
20
|
+
// Append one snapshot row iff the totals differ from the last one on record for
|
|
21
|
+
// this (client, builder) pair — ONE statement, not a read then a write: the CTE
|
|
22
|
+
// and the conditional INSERT run in the same round trip, closing the two-round-
|
|
23
|
+
// trip check-then-insert race the earlier design had. A DB-level race remains
|
|
24
|
+
// under READ COMMITTED — two truly concurrent statements for the same pair can
|
|
25
|
+
// each see no conflicting row and both insert — but the worst case is a harmless
|
|
26
|
+
// duplicate snapshot with IDENTICAL totals, which reads the same either way in
|
|
27
|
+
// the windowed-delta arithmetic. A first-ever report (no prior row) always
|
|
28
|
+
// inserts — NOT EXISTS is true against an empty CTE regardless of the
|
|
29
|
+
// comparison. `id DESC` breaks a tie on an identical `reported_at` so "the last
|
|
30
|
+
// snapshot" is always one specific row.
|
|
31
|
+
async function recordActivitySnapshot({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0 }, { pool = defaultPool } = {}) {
|
|
32
|
+
const { rows } = await pool.query(
|
|
33
|
+
`WITH last AS (
|
|
34
|
+
SELECT credits, tasks_shipped, karma FROM platform_identity_activity_snapshots
|
|
35
|
+
WHERE client_id = $1 AND github_id = $2
|
|
36
|
+
ORDER BY reported_at DESC, id DESC LIMIT 1
|
|
37
|
+
)
|
|
38
|
+
INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at)
|
|
39
|
+
SELECT $1, $2, $3, $4, $5, now()
|
|
40
|
+
WHERE NOT EXISTS (SELECT 1 FROM last WHERE credits = $3 AND tasks_shipped = $4 AND karma = $5)
|
|
41
|
+
RETURNING id, reported_at`,
|
|
42
|
+
[clientId, githubId, credits, tasksShipped, karma],
|
|
43
|
+
);
|
|
44
|
+
return rows[0] || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { recordActivitySnapshot };
|