@bongos/core 1.19.915 → 1.19.916

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.
@@ -0,0 +1,103 @@
1
+ // modules/ideas/projection.js — the SHAPE every idea read returns (task 1004115).
2
+ //
3
+ // Carved out of inbox.js, which sat at exactly the 1,500-line ratchet ceiling
4
+ // (scripts/gds/fitness-ratchets.js OVERSIZE_LINES) and could therefore not grow
5
+ // by one line. That ceiling had started deciding the design rather than the
6
+ // domain: modules/ideas/CLAUDE.md records two files (trail-descent.js,
7
+ // trail-size.js) that exist partly because of it, and task 1004084 shipped an
8
+ // inline require() inside a call expression to save a line.
9
+ //
10
+ // THIS FILE IS THE BOTTOM OF THE MODULE. It holds the column lists, the grade
11
+ // vocabulary and the join fragment that the reads in reads.js, trail.js and
12
+ // grades.js all splice into SQL — the "one column list every idea read returns"
13
+ // argument below is exactly why they must share a file rather than a copy. It
14
+ // requires NOTHING: no doorway, no pool, no sibling. That is deliberate and
15
+ // load-bearing — every other file in the carve depends on this one, so anything
16
+ // added here that reached back up would make the module cyclic
17
+ // (require_cycle_count is a baselined ratchet).
18
+
19
+ // The one column list every idea read returns. Four copies had to stay in step
20
+ // (capture / list / get / update-RETURNING); core_195 added two columns to each,
21
+ // so it is now a constant rather than four places to forget. Kept as names rather
22
+ // than a string so the qualified form (task 1002916 joined the grade table in, and
23
+ // an unqualified `id` is ambiguous across a join) is derived, not a fifth copy.
24
+ const IDEA_COL_NAMES = [
25
+ 'id', 'title', 'body_md', 'kind', 'suggested_version', 'suggested_priority',
26
+ 'captured_at', 'captured_by', 'status', 'promoted_to_task_id', 'reviewed_at',
27
+ 'merged_into_idea_id', 'triage_note',
28
+ // core_222 (task 1003060, BV1.R02): the routing HINT — "this probably belongs
29
+ // to goal N" — on a row that did NOT convert. Added to the shared list because
30
+ // R02 shipped the column and nothing read it: /idea-triage (R13) cannot walk
31
+ // homeless-only work if the read that feeds it never returns the hint. Strictly
32
+ // advisory: no rank check, no scope wall, and no payout may read it (see the
33
+ // migration's own header). The BINDING home for "which goal" is tasks.goal_id.
34
+ 'suggested_goal_id',
35
+ ];
36
+ const IDEA_COLS = IDEA_COL_NAMES.join(', ');
37
+ const IDEA_COLS_Q = IDEA_COL_NAMES.map((c) => `i.${c}`).join(', ');
38
+
39
+ // -------------------------------------------------------------------------
40
+ // Idea grades (task 1002916 / BV1.R140, goal 1000062)
41
+ //
42
+ // Three grades, from the owner's brief:
43
+ // quick — title + description, open to every role, aimed at turning a thought
44
+ // into a task fast. NOT a draft Full Idea.
45
+ // spark — a Quick Idea whose AUTHOR marked it as needing development. The mark
46
+ // is the consent that makes cross-builder development fair (R144).
47
+ // full — carries the base template's five fields, scored on completeness.
48
+ //
49
+ // AN IDEA WITH NO ideas_grades ROW READS AS QUICK. That is the design, not a
50
+ // fallback: every idea captured before this shipped keeps grade='quick' without
51
+ // a row being written or a column being backfilled. The COALESCE below is where
52
+ // that rule lives, and it is the reason the reads LEFT JOIN rather than JOIN.
53
+ // An explicit quick row is equally legal (un-sparking writes one) and reads the
54
+ // same, so callers never have to care which of the two they are looking at.
55
+ const DEFAULT_GRADE = 'quick';
56
+ const VALID_GRADES = ['quick', 'spark', 'full'];
57
+
58
+ // Which template `field_values` was written against. Stored per row so a project
59
+ // that later defines its own shape (R156) does not retro-reinterpret ideas
60
+ // written against the base one. R141 declares what 'base-v1' actually contains —
61
+ // this module only records which template a row used.
62
+ const DEFAULT_TEMPLATE_KEY = 'base-v1';
63
+ const TEMPLATE_KEY_RE = /^[a-z][a-z0-9-]*$/;
64
+ const TEMPLATE_KEY_MAX = 64;
65
+
66
+ // Mirrors ideas_grades_field_values_size_chk. Declared here too so an oversize
67
+ // payload gets a structured 400 instead of a constraint-violation 500.
68
+ const FIELD_VALUES_MAX = 32768;
69
+
70
+ // The grade half of an idea read: the COALESCE above, plus the template-shaped
71
+ // store and the score. `g.*` columns are NULL for an ungraded idea, which is
72
+ // exactly right — it has no template and no score, it just has a default grade.
73
+ // developed_by/developed_at (ideas_005, task 1002922 / BV1.R146) ride along on
74
+ // EVERY idea read rather than needing a second query: "who developed this" is
75
+ // part of an idea's identity once it has been developed, and the R145 queue, the
76
+ // hall detail view (R153) and the R147 credit split all want it. NULL for the
77
+ // overwhelming majority of rows — a quick idea and a self-authored Full Idea were
78
+ // never developed by anyone — which is the honest answer, not a missing one.
79
+ const GRADE_COLS_Q = `COALESCE(g.grade, '${DEFAULT_GRADE}') AS grade,
80
+ g.template_key, g.field_values, g.completeness_score,
81
+ g.developed_by, g.developed_at`;
82
+ const IDEA_JOIN_GRADE = `LEFT JOIN ideas_grades g ON g.idea_id = i.id`;
83
+
84
+ // The same projection for a row that provably has no grade row yet (a just-
85
+ // INSERTed idea), so INSERT…RETURNING does not need a second round trip to
86
+ // return the same shape every other read returns.
87
+ function ungradedProjection() {
88
+ return { grade: DEFAULT_GRADE, template_key: null, field_values: null, completeness_score: null, developed_by: null, developed_at: null };
89
+ }
90
+
91
+ module.exports = {
92
+ IDEA_COLS,
93
+ IDEA_COLS_Q,
94
+ DEFAULT_GRADE,
95
+ VALID_GRADES,
96
+ DEFAULT_TEMPLATE_KEY,
97
+ TEMPLATE_KEY_RE,
98
+ TEMPLATE_KEY_MAX,
99
+ FIELD_VALUES_MAX,
100
+ GRADE_COLS_Q,
101
+ IDEA_JOIN_GRADE,
102
+ ungradedProjection,
103
+ };
@@ -0,0 +1,211 @@
1
+ // modules/ideas/ratify.js — criterion-proposal ratification (task 1004115;
2
+ // the transaction itself is V3.R31).
3
+ //
4
+ // Carved out of inbox.js verbatim, and the most clearly separable thing in it:
5
+ // this is the ONE place the ideas module writes done_when_criteria, karma_log
6
+ // and criterion_proposal_audit — three tables no other function here touches —
7
+ // in a single transaction. It reads one idea_inbox row and never uses the
8
+ // shared idea projection, so unlike every other carved file it depends on
9
+ // nothing but the doorway.
10
+ //
11
+ // It is a PLANNING write wearing an idea's clothes: a Metic proposes through
12
+ // the inbox because that is the capture surface everyone already has, and an
13
+ // Archon ratifies it into a version's scope. Keeping it beside the triage
14
+ // writes made the two look like the same kind of act. They are not.
15
+
16
+ const { pool } = require('../../src/module-api');
17
+
18
+ // -------------------------------------------------------------------------
19
+ // Criterion-proposal ratification (V3.R31 / #252)
20
+ //
21
+ // Atomically promotes an idea_inbox row with kind='criterion-proposal' into a
22
+ // real done_when_criteria row, marks the idea as promoted, awards karma to
23
+ // the proposer (delta=+3, reason='proposal_ratified'), and writes an audit
24
+ // row capturing who proposed / who ratified / when / what.
25
+ //
26
+ // Called by POST /api/bongos/inbox/:id/ratify (Archon-only, gated by requireRank
27
+ // in the router). The body_md of the source idea is parsed as JSON with the
28
+ // shape { target_version, criterion_md, sort_order_hint }; malformed bodies
29
+ // throw BAD_BODY so the route can return a structured 400.
30
+ //
31
+ // Karma amount is tunable; V3.R28 (#248) sets it to +3 to recognize good civic
32
+ // input without overweighting it relative to peer-upvote signals that arrive
33
+ // in volume. criterion_id on the new done_when_criteria row defaults to
34
+ // "proposal-<idea_id>" so the (version_id, criterion_id) UNIQUE constraint
35
+ // fires if someone ratifies the same idea twice — the second attempt fails the
36
+ // transaction rather than creating a duplicate criterion silently.
37
+ const KARMA_RATIFY_DELTA = 3;
38
+ const KARMA_RATIFY_REASON = 'proposal_ratified';
39
+
40
+ function parseProposalBody(bodyMd) {
41
+ if (!bodyMd || typeof bodyMd !== 'string') {
42
+ throw { code: 'BAD_BODY', reason: 'body_md_empty' };
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(bodyMd);
47
+ } catch (err) {
48
+ throw { code: 'BAD_BODY', reason: 'body_md_not_json', detail: err.message };
49
+ }
50
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
51
+ throw { code: 'BAD_BODY', reason: 'body_md_not_object' };
52
+ }
53
+ const { target_version, criterion_md, sort_order_hint } = parsed;
54
+ if (!target_version || typeof target_version !== 'string') {
55
+ throw { code: 'BAD_BODY', reason: 'target_version_required' };
56
+ }
57
+ if (!criterion_md || typeof criterion_md !== 'string') {
58
+ throw { code: 'BAD_BODY', reason: 'criterion_md_required' };
59
+ }
60
+ let hint = null;
61
+ if (sort_order_hint != null) {
62
+ const n = Number(sort_order_hint);
63
+ if (!Number.isFinite(n) || !Number.isInteger(n)) {
64
+ throw { code: 'BAD_BODY', reason: 'sort_order_hint_not_integer' };
65
+ }
66
+ hint = n;
67
+ }
68
+ return { target_version, criterion_md, sort_order_hint: hint };
69
+ }
70
+
71
+ async function ratifyCriterionProposal(ideaId, ratifierId, { notesMd = '' } = {}) {
72
+ const id = Number(ideaId);
73
+ if (!Number.isFinite(id)) {
74
+ throw { code: 'BAD_ID' };
75
+ }
76
+ const rid = Number(ratifierId);
77
+ if (!Number.isFinite(rid)) {
78
+ throw { code: 'BAD_RATIFIER' };
79
+ }
80
+ const client = await pool.connect();
81
+ try {
82
+ await client.query('BEGIN');
83
+
84
+ // a. Read + lock the idea row.
85
+ const { rows: ideaRows } = await client.query(
86
+ `SELECT id, title, body_md, kind, status, captured_by
87
+ FROM idea_inbox
88
+ WHERE id = $1
89
+ FOR UPDATE`,
90
+ [id]
91
+ );
92
+ if (ideaRows.length === 0) throw { code: 'IDEA_NOT_FOUND' };
93
+ const idea = ideaRows[0];
94
+ if (idea.kind !== 'criterion-proposal') {
95
+ throw { code: 'WRONG_KIND', kind: idea.kind, expected: 'criterion-proposal' };
96
+ }
97
+ if (idea.status !== 'open') {
98
+ throw { code: 'IDEA_NOT_OPEN', status: idea.status };
99
+ }
100
+
101
+ // b. Parse body_md.
102
+ const proposal = parseProposalBody(idea.body_md);
103
+
104
+ // c. Verify target version exists. (FK on done_when_criteria.version_id
105
+ // would catch this too, but giving a 400 instead of a 23503 is friendlier.)
106
+ const { rows: verRows } = await client.query(
107
+ `SELECT id FROM versions WHERE id = $1`,
108
+ [proposal.target_version]
109
+ );
110
+ if (verRows.length === 0) {
111
+ throw { code: 'VERSION_NOT_FOUND', version_id: proposal.target_version };
112
+ }
113
+
114
+ // d. Resolve sort_order: prefer the hint; otherwise max(existing)+1, or 100
115
+ // if no rows exist yet (matches the migration 006 default).
116
+ let sortOrder = proposal.sort_order_hint;
117
+ if (sortOrder == null) {
118
+ const { rows: maxRows } = await client.query(
119
+ `SELECT COALESCE(MAX(sort_order), 0) + 1 AS next
120
+ FROM done_when_criteria
121
+ WHERE version_id = $1`,
122
+ [proposal.target_version]
123
+ );
124
+ sortOrder = maxRows[0].next;
125
+ }
126
+
127
+ // e. INSERT done_when_criteria with criterion_id = 'proposal-<idea_id>'.
128
+ // The UNIQUE (version_id, criterion_id) constraint provides natural
129
+ // idempotency — re-ratifying the same idea would 23505 here.
130
+ const criterionRefId = `proposal-${idea.id}`;
131
+ const { rows: critRows } = await client.query(
132
+ `INSERT INTO done_when_criteria
133
+ (version_id, criterion_id, criterion_md, sort_order)
134
+ VALUES ($1, $2, $3, $4)
135
+ RETURNING id, version_id, criterion_id, criterion_md, sort_order`,
136
+ [proposal.target_version, criterionRefId, proposal.criterion_md, sortOrder]
137
+ );
138
+ const criterion = critRows[0];
139
+
140
+ // f. Mark the idea promoted. promoted_to_task_id stays NULL — a criterion
141
+ // is not a task. reviewed_at is stamped to now() so triage views skip it.
142
+ await client.query(
143
+ `UPDATE idea_inbox
144
+ SET status = 'promoted',
145
+ promoted_to_task_id = NULL,
146
+ reviewed_at = now()
147
+ WHERE id = $1`,
148
+ [idea.id]
149
+ );
150
+
151
+ // g. Award karma to the proposer. captured_by may be NULL on legacy rows;
152
+ // skip the karma insert in that case (no proposer to credit) but still
153
+ // record the audit row.
154
+ let karmaAwarded = 0;
155
+ if (idea.captured_by != null) {
156
+ await client.query(
157
+ `INSERT INTO karma_log (builder_id, delta, reason, granted_by)
158
+ VALUES ($1, $2, $3, $4)`,
159
+ [idea.captured_by, KARMA_RATIFY_DELTA, KARMA_RATIFY_REASON, rid]
160
+ );
161
+ karmaAwarded = KARMA_RATIFY_DELTA;
162
+
163
+ // Note: prior to 2026-05-30 this also ticked a 'first_vote_received'
164
+ // onboarding stage. That stage was removed (migration 059) — graduation
165
+ // no longer waits on a peer vote — so there is nothing to mark here now.
166
+ }
167
+
168
+ // h. Audit row — durable record of the ratification event.
169
+ const { rows: auditRows } = await client.query(
170
+ `INSERT INTO criterion_proposal_audit
171
+ (idea_id, criterion_id, proposed_by, ratified_by,
172
+ target_version, criterion_md, sort_order_hint, notes_md)
173
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
174
+ RETURNING id, ratified_at`,
175
+ [
176
+ idea.id,
177
+ criterion.id,
178
+ idea.captured_by,
179
+ rid,
180
+ proposal.target_version,
181
+ proposal.criterion_md,
182
+ proposal.sort_order_hint,
183
+ notesMd || '',
184
+ ]
185
+ );
186
+
187
+ await client.query('COMMIT');
188
+ return {
189
+ criterion_id: criterion.id,
190
+ criterion_ref: criterion.criterion_id,
191
+ version_id: criterion.version_id,
192
+ sort_order: criterion.sort_order,
193
+ audit_id: auditRows[0].id,
194
+ ratified_at: auditRows[0].ratified_at,
195
+ karma_awarded: karmaAwarded,
196
+ proposer_id: idea.captured_by,
197
+ };
198
+ } catch (err) {
199
+ await client.query('ROLLBACK').catch(() => {});
200
+ throw err;
201
+ } finally {
202
+ client.release();
203
+ }
204
+ }
205
+
206
+ module.exports = {
207
+ KARMA_RATIFY_DELTA,
208
+ KARMA_RATIFY_REASON,
209
+ parseProposalBody,
210
+ ratifyCriterionProposal,
211
+ };
@@ -0,0 +1,96 @@
1
+ // modules/ideas/reads.js — the three plain idea reads (task 1004115).
2
+ //
3
+ // listOpenIdeas / getIdea / countOpenIdeas, carved out of inbox.js verbatim.
4
+ // They are grouped because they are the reads with no second relation in them:
5
+ // the trail (trail.js) joins lineage and outcomes, the spark queue (grades.js)
6
+ // starts from ideas_grades, the descent (trail-descent.js) walks task-to-task.
7
+ // These three just read idea_inbox with its grade attached.
8
+ //
9
+ // IT SITS BELOW grades.js ON PURPOSE. developSpark needs getIdea to build the
10
+ // idea.graded.full payload, and updateIdea in inbox.js needs it too. Leaving it
11
+ // in inbox.js would have made inbox -> grades -> inbox a require cycle, which
12
+ // is a baselined ratchet (require_cycle_count). Reads depend on projection.js
13
+ // and skill-friction.js and on nothing above them.
14
+
15
+ const { pool } = require('../../src/module-api');
16
+ const { IDEA_COLS_Q, GRADE_COLS_Q, IDEA_JOIN_GRADE } = require('./projection');
17
+ const { skillFrictionHiddenClause } = require('./skill-friction');
18
+
19
+ // -------------------------------------------------------------------------
20
+ // Read
21
+ // -------------------------------------------------------------------------
22
+
23
+ // viewerRank gates skill-friction visibility (V3.R70 / #289): Xenos must not
24
+ // see skill-friction rows; Metic+ (or unknown rank, pre-R04) see everything.
25
+ // We filter in SQL when the viewer is positively Xenos so the LIMIT still
26
+ // returns up to `limit` *visible* rows rather than `limit` rows that then get
27
+ // thinned out in JS. The marker is a kind OR a legacy title prefix — see
28
+ // skillFrictionHiddenClause, which is the only place that knows that.
29
+ async function listOpenIdeas({ kind = null, limit = 50, viewerRank = null } = {}) {
30
+ const params = [];
31
+ const where = [`i.status = 'open'`];
32
+ if (kind) {
33
+ params.push(kind);
34
+ where.push(`i.kind = $${params.length}`);
35
+ }
36
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
37
+ if (hideFriction) where.push(hideFriction);
38
+ params.push(limit);
39
+ const { rows } = await pool.query(
40
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q}
41
+ FROM idea_inbox i
42
+ ${IDEA_JOIN_GRADE}
43
+ WHERE ${where.join(' AND ')}
44
+ ORDER BY i.captured_at DESC
45
+ LIMIT $${params.length}`,
46
+ params
47
+ );
48
+ return rows;
49
+ }
50
+
51
+ // The by-id read. It carries the two people's LOGINS as well as their ids
52
+ // (task 1002929 / R153): an idea's attribution is a claim about humans, and
53
+ // "captured_by: 41" is not attribution to anyone reading it. Named to match the
54
+ // R145 spark queue's `filed_by_login` so the two idea surfaces agree.
55
+ //
56
+ // Both joins are LEFT: a builder row can be missing (an FK that went
57
+ // ON DELETE SET NULL), and an idea nobody developed has no developer at all —
58
+ // which is the common case, not an error. A plain JOIN here would drop most of
59
+ // the inbox, the same trap the grade join documents above.
60
+ async function getIdea(id) {
61
+ const { rows } = await pool.query(
62
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
63
+ ba.github_login AS filed_by_login,
64
+ bd.github_login AS developed_by_login
65
+ FROM idea_inbox i
66
+ ${IDEA_JOIN_GRADE}
67
+ LEFT JOIN builders ba ON ba.id = i.captured_by
68
+ LEFT JOIN builders bd ON bd.id = g.developed_by
69
+ WHERE i.id = $1`,
70
+ [id]
71
+ );
72
+ return rows[0] ?? null;
73
+ }
74
+
75
+ // viewerRank applies the SAME skill-friction visibility gate as listOpenIdeas
76
+ // (V3.R70 / #289) so the count a viewer is shown matches the rows they can see:
77
+ // a Xenos must not be told there are N open ideas when [skill-friction] rows
78
+ // are filtered out of their list. Default null preserves the old "count
79
+ // everything" behaviour for callers that don't have (or don't gate on) a rank.
80
+ async function countOpenIdeas({ viewerRank = null } = {}) {
81
+ const params = [];
82
+ const where = [`status = 'open'`];
83
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params);
84
+ if (hideFriction) where.push(hideFriction);
85
+ const { rows } = await pool.query(
86
+ `SELECT count(*)::int AS n FROM idea_inbox WHERE ${where.join(' AND ')}`,
87
+ params
88
+ );
89
+ return rows[0].n;
90
+ }
91
+
92
+ module.exports = {
93
+ listOpenIdeas,
94
+ getIdea,
95
+ countOpenIdeas,
96
+ };
@@ -0,0 +1,111 @@
1
+ // modules/ideas/trail.js — THE IDEA TRAIL: every idea one builder filed, and
2
+ // what became of each (task 1004115; the read itself is task 1003281).
3
+ //
4
+ // Carved out of inbox.js verbatim. It joins its own siblings — trail-descent.js
5
+ // hangs the task-to-task walk under these rows, trail-size.js hangs what the
6
+ // descent has paid — and both of those were put in their own file for the
7
+ // reason this carve now applies to the read itself: inbox.js was AT the
8
+ // 1,500-line ratchet, so "a different relation deserves a different file" and
9
+ // "nothing more fits" had become the same sentence. They are separable again.
10
+ // The trail is a read keyed by PERSON; inbox.js is the capture chokepoint and
11
+ // the triage writes.
12
+
13
+ const { pool } = require('../../src/module-api');
14
+ const { IDEA_COLS_Q, GRADE_COLS_Q, IDEA_JOIN_GRADE } = require('./projection');
15
+ const { skillFrictionHiddenClause } = require('./skill-friction');
16
+
17
+ // -------------------------------------------------------------------------
18
+ // The idea TRAIL — every idea one builder filed, and what became of it
19
+ // (task 1003281, goal 1000072, criterion legibility-activity-by-role).
20
+ //
21
+ // THE READ THAT EXISTS NOWHERE ELSE, and the reason the criterion was written:
22
+ // the owner could file an idea and then never find out what happened to it.
23
+ // Every other idea read is scoped to what is STILL OPEN — listOpenIdeas, the
24
+ // spark queue, the nod queue — so a promoted, discarded or merged idea drops
25
+ // out of every surface the moment it is triaged, which is exactly when its
26
+ // outcome becomes interesting. This read is scoped to a PERSON instead of to a
27
+ // status, so nothing they filed ever leaves it.
28
+ //
29
+ // NO NEW STORAGE. The four outcomes are already on the row: `status` says which
30
+ // one, `promoted_to_task_id` + ideas_task_lineage say which task a promotion
31
+ // produced, `merged_into_idea_id` says which idea absorbed a merge, and
32
+ // `triage_note` carries the triager's sentence (core_195, task 1002447). The
33
+ // joins below turn those ids into names so a reader gets "became task #1003281
34
+ // — See what you did, by role" rather than a number they must go look up.
35
+ //
36
+ // AN OPEN IDEA IS AN OUTCOME. It reads as "still open", never as an absence —
37
+ // one of the four the criterion names. That is why status is returned verbatim
38
+ // and the surface renders it, rather than the read filtering open rows out.
39
+ //
40
+ // LINEAGE, NOT JUST THE LEGACY COLUMN. One idea can become MANY tasks
41
+ // (ideas_task_lineage, task 1002997), while `promoted_to_task_id` holds only the
42
+ // first. Both are returned: the lineage array is the complete answer, the legacy
43
+ // column is the only answer for promotions that predate the lineage table. The
44
+ // LATERAL is bounded by the page window, so this stays one query, not an N+1.
45
+ const IDEA_TRAIL_DEFAULT_LIMIT = 20;
46
+ const IDEA_TRAIL_MAX_LIMIT = 100;
47
+
48
+ // The WHERE this list and its count share, so the pager can never claim rows the
49
+ // list will not show. viewerRank applies the SAME skill-friction gate as every
50
+ // other idea read (V3.R70 / #289) — including on a builder reading their OWN
51
+ // trail: the gate is about what a rank may see, not about authorship.
52
+ function ideaTrailWhere(builderId, viewerRank, params) {
53
+ params.push(builderId);
54
+ const where = [`i.captured_by = $${params.length}`];
55
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
56
+ if (hideFriction) where.push(hideFriction);
57
+ return where.join(' AND ');
58
+ }
59
+
60
+ async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_LIMIT, offset = 0, viewerRank = null } = {}, deps = {}) {
61
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
62
+ const lim = Math.min(Math.max(Number.isInteger(Number(limit)) ? Number(limit) : IDEA_TRAIL_DEFAULT_LIMIT, 1), IDEA_TRAIL_MAX_LIMIT);
63
+ const off = Number.isInteger(Number(offset)) && Number(offset) > 0 ? Number(offset) : 0;
64
+ const params = [];
65
+ const where = ideaTrailWhere(builderId, viewerRank, params);
66
+ params.push(lim, off);
67
+ const { rows } = await activePool.query(
68
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
69
+ pt.title AS promoted_task_title,
70
+ pt.status AS promoted_task_status,
71
+ mi.title AS merged_into_idea_title,
72
+ mi.status AS merged_into_idea_status,
73
+ COALESCE(lin.tasks, '[]'::json) AS lineage_tasks
74
+ FROM idea_inbox i
75
+ ${IDEA_JOIN_GRADE}
76
+ LEFT JOIN tasks pt ON pt.id = i.promoted_to_task_id
77
+ LEFT JOIN idea_inbox mi ON mi.id = i.merged_into_idea_id
78
+ LEFT JOIN LATERAL (
79
+ SELECT json_agg(json_build_object(
80
+ 'task_id', l.task_id, 'origin', l.origin,
81
+ 'title', lt.title, 'status', lt.status
82
+ ) ORDER BY l.task_id) AS tasks
83
+ FROM ideas_task_lineage l
84
+ JOIN tasks lt ON lt.id = l.task_id
85
+ WHERE l.idea_id = i.id
86
+ ) lin ON true
87
+ WHERE ${where}
88
+ ORDER BY i.captured_at DESC, i.id DESC
89
+ LIMIT $${params.length - 1} OFFSET $${params.length}`,
90
+ params
91
+ );
92
+ return rows;
93
+ }
94
+
95
+ async function countIdeaTrailForBuilder(builderId, { viewerRank = null } = {}, deps = {}) {
96
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
97
+ const params = [];
98
+ const where = ideaTrailWhere(builderId, viewerRank, params);
99
+ const { rows } = await activePool.query(
100
+ `SELECT count(*)::int AS n FROM idea_inbox i WHERE ${where}`,
101
+ params
102
+ );
103
+ return rows[0].n;
104
+ }
105
+
106
+ module.exports = {
107
+ IDEA_TRAIL_DEFAULT_LIMIT,
108
+ IDEA_TRAIL_MAX_LIMIT,
109
+ listIdeaTrailForBuilder,
110
+ countIdeaTrailForBuilder,
111
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.915",
3
+ "version": "1.19.916",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.915",
9
+ "version": "1.19.916",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.915",
3
+ "version": "1.19.916",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.915'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.916'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -182,13 +182,13 @@ test('the event is in the catalog AND something dispatches it', () => {
182
182
  // The catalog's own comment: widening it is deliberate and must land together
183
183
  // with a real dispatcher. Both halves asserted, because either alone is dead.
184
184
  assert.match(src('modules/agents/lib/validate.js'), /'idea\.graded\.full'/);
185
- assert.match(src('modules/ideas/inbox.js'), /api\.emit\('idea\.graded\.full'/);
185
+ assert.match(src('modules/ideas/grades.js'), /api\.emit\('idea\.graded\.full'/);
186
186
  });
187
187
 
188
188
  test('the emit carries the whole idea, not just an id', () => {
189
189
  // The listener lives in lifecycle, which owns no idea table and has no read
190
190
  // port for one — so a bare {id} payload would leave the rules nothing to match.
191
- const inbox = src('modules/ideas/inbox.js');
191
+ const inbox = src('modules/ideas/grades.js'); // developSpark lives here since the task 1004115 carve
192
192
  assert.match(inbox, /const idea = await getIdea\(ideaId\)/);
193
193
  assert.match(inbox, /\.\.\.\(idea \|\| \{\}\)/);
194
194
  });
@@ -204,7 +204,7 @@ test('lifecycle subscribes to the idea trigger and tears it down again', () => {
204
204
  test('the emit only fires on a REAL transition', () => {
205
205
  // The UPDATE carries `AND grade = 'spark'`, so a no-op re-grade returns null.
206
206
  // The emit is inside `if (graded)`, so it cannot repeat for one idea.
207
- const inbox = src('modules/ideas/inbox.js');
207
+ const inbox = src('modules/ideas/grades.js'); // developSpark lives here since the task 1004115 carve
208
208
  const at = inbox.indexOf("api.emit('idea.graded.full'");
209
209
  assert.ok(at > 0);
210
210
  assert.match(inbox.slice(0, at).split('\n').slice(-25).join('\n'), /if \(graded\) \{/);