@bongos/core 1.19.454 → 1.19.456

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.
Files changed (58) hide show
  1. package/.bongos-core.json +116 -51
  2. package/docs/adr/0047-discord-bugs-intake-anti-abuse.md +1 -0
  3. package/docs/copy-inventory.md +231 -228
  4. package/docs/copy-registry.json +352 -312
  5. package/docs/module-api-changelog.md +4 -0
  6. package/modules/discord/db.js +0 -3
  7. package/modules/discord/discord-bot.js +8 -1
  8. package/modules/discord/discord-bugs.js +133 -74
  9. package/modules/discord/module.json +1 -1
  10. package/modules/hall-ui/public/atlas.css +41 -32
  11. package/modules/hall-ui/public/atlas.html +8 -0
  12. package/modules/hall-ui/public/atlas.states.json +60 -0
  13. package/modules/hall-ui/public/collab.css +2 -2
  14. package/modules/hall-ui/public/collab.html +7 -1
  15. package/modules/hall-ui/public/collab.states.json +60 -0
  16. package/modules/hall-ui/public/copy-desk.css +21 -16
  17. package/modules/hall-ui/public/copy-desk.html +9 -1
  18. package/modules/hall-ui/public/copy-desk.states.json +59 -0
  19. package/modules/hall-ui/public/drachmae.html +38 -5
  20. package/modules/hall-ui/public/drachmae.js +20 -2
  21. package/modules/hall-ui/public/drachmae.states.json +64 -0
  22. package/modules/hall-ui/public/gate.css +20 -25
  23. package/modules/hall-ui/public/gate.html +9 -1
  24. package/modules/hall-ui/public/gate.states.json +60 -0
  25. package/modules/hall-ui/public/goals-lib.js +22 -5
  26. package/modules/hall-ui/public/goals-render.js +27 -5
  27. package/modules/hall-ui/public/goals.css +141 -60
  28. package/modules/hall-ui/public/goals.html +8 -1
  29. package/modules/hall-ui/public/goals.states.json +74 -0
  30. package/modules/hall-ui/public/government.css +35 -36
  31. package/modules/hall-ui/public/government.html +9 -1
  32. package/modules/hall-ui/public/government.states.json +59 -0
  33. package/modules/hall-ui/public/harbor.css +23 -26
  34. package/modules/hall-ui/public/harbor.html +9 -1
  35. package/modules/hall-ui/public/harbor.states.json +59 -0
  36. package/modules/hall-ui/public/modules.css +22 -21
  37. package/modules/hall-ui/public/modules.html +9 -1
  38. package/modules/hall-ui/public/modules.states.json +73 -0
  39. package/modules/hall-ui/public/people.html +7 -1
  40. package/modules/hall-ui/public/people.states.json +59 -0
  41. package/modules/hall-ui/public/scouting.html +7 -1
  42. package/modules/hall-ui/public/scouting.states.json +54 -0
  43. package/modules/hall-ui/public/sessions.css +1 -1
  44. package/modules/hall-ui/public/sessions.html +9 -1
  45. package/modules/hall-ui/public/sessions.states.json +58 -0
  46. package/modules/hall-ui/public/style.css +125 -5
  47. package/modules/hall-ui/public/watch.css +36 -39
  48. package/modules/hall-ui/public/watch.html +9 -1
  49. package/modules/hall-ui/public/watch.states.json +60 -0
  50. package/modules/lifecycle/goal-suggest.js +70 -2
  51. package/modules/lifecycle/lifecycle.js +7 -0
  52. package/package-lock.json +2 -2
  53. package/package.json +1 -1
  54. package/src/module-api.js +1 -1
  55. package/tests/discord_bugs.mjs +110 -31
  56. package/tests/filing_abuse_matrix.mjs +59 -20
  57. package/tests/goal_subtitle.mjs +14 -8
  58. package/tests/goal_suggest.mjs +107 -1
@@ -26,10 +26,14 @@
26
26
  // really does belong with a goal scoped to `hall-ui`.
27
27
 
28
28
  const { tokenize } = require('./criterion-suggest.js');
29
- const { candidateGoals } = require('./goal-advisory.js');
29
+ const { versionAcceptsNewWork } = require('./db-tasks.js');
30
+ const { candidateGoals, generalGoalTitle } = require('./goal-advisory.js');
30
31
  const api = require('../../src/module-api');
31
32
  // Through the published logger seam, not a raw console call (est-advisory.js precedent).
32
33
  const proseErr = (err) => api.logger('lifecycle').error({ err: err && err.message }, 'GET /goals/suggestions failed');
34
+ // The capture-time twin has its own message: it is not an HTTP route, and a log
35
+ // line that names the wrong caller is a wrong turn taken during an incident.
36
+ const captureErr = (err) => api.logger('lifecycle').error({ err: err && err.message }, 'suggestGoalForCapture failed — filing without a goal');
33
37
 
34
38
  // Matches suggestCriteria's default so the two suggesters feel the same.
35
39
  const SUGGEST_LIMIT = 5;
@@ -82,6 +86,70 @@ function suggestGoals(goals, draft = {}, opts = {}) {
82
86
  }
83
87
 
84
88
 
89
+ // suggestGoalForCapture — ONE goal for a filing that arrived with no goal named
90
+ // (task 1003067 / BV1.R11). The HTTP suggester above hands a human five options to
91
+ // choose from; a Discord message has nobody to choose, so this collapses the same
92
+ // ranking to a single confident answer or none at all.
93
+ //
94
+ // WHY IT RESOLVES THE VERSION ITSELF. The vector this was built for — Discord
95
+ // #bugs — used to carry a hardcoded version id, and because that id was a SHIPPED
96
+ // version every bug reported through it landed in a tomb nobody reads (idea
97
+ // 1000771, the failure goal 1000071 exists to fix). A filing vector should not be
98
+ // in the business of naming a version at all, so it does not: the GOAL carries the
99
+ // version, and this picks the goal.
100
+ //
101
+ // It therefore ranks across EVERY version still open to work (versionAcceptsNewWork,
102
+ // task 1003061), not one. An earlier cut of this declined whenever more than one
103
+ // version was open — prudent-looking, and a dead feature: this instance runs two
104
+ // building versions concurrently, so it would have returned null for every report
105
+ // ever filed. "Which version" is not a question worth asking when the answer is
106
+ // already attached to the goal that won.
107
+ //
108
+ // Catch-all goals are excluded for every one of those versions. candidateGoals
109
+ // drops the catch-all for ONE version id, which is the wrong shape here, so the
110
+ // filtering happens up front — a "<version> — general" bucket is never a home
111
+ // (that is R03's fallback question, a different one).
112
+ //
113
+ // Returns { id, title, version_id, score } or NULL. Null is the ordinary case and
114
+ // a good one: no goal means the filing lands in the inbox, exactly as every
115
+ // goal-unaware capture always has. Never throws — a suggester that takes down a
116
+ // bug report is worse than one that shrugs.
117
+ async function suggestGoalForCapture({ title = '', body = '' } = {}, deps = {}) {
118
+ const db = deps.db || require('./db.js');
119
+ try {
120
+ if (!String(title || '').trim() && !String(body || '').trim()) return null;
121
+
122
+ // Independent reads — issued together rather than in sequence.
123
+ const [versions, allGoals] = await Promise.all([db.listVersions(), db.listGoals({ status: 'open' })]);
124
+ const openIds = new Set(
125
+ (versions || []).filter((v) => versionAcceptsNewWork(v && v.status)).map((v) => String(v.id))
126
+ );
127
+ // Nothing is being built ⇒ nowhere for new work to go ⇒ the inbox.
128
+ if (openIds.size === 0) return null;
129
+
130
+ const goals = allGoals || [];
131
+ const homes = goals.filter((g) => g
132
+ && openIds.has(String(g.version_id))
133
+ && g.title !== generalGoalTitle(g.version_id));
134
+ if (homes.length === 0) return null;
135
+
136
+ // versionId is passed null: the catch-alls are already gone, and passing one
137
+ // version's id here would only re-filter for that version. includeZero stays
138
+ // false, so a filing that matches nothing gets NO goal rather than the
139
+ // first one dressed up as a suggestion.
140
+ const [best] = suggestGoals(homes, { title, body }, { versionId: null, limit: 1 });
141
+ if (!best) return null;
142
+
143
+ // suggestGoals normalises to { id, title, scope_modules, … } and drops
144
+ // version_id, so read it back off the row that won.
145
+ const row = homes.find((g) => String(g.id) === String(best.id));
146
+ return { id: best.id, title: best.title, version_id: row ? row.version_id : null, score: best.score };
147
+ } catch (err) {
148
+ captureErr(err);
149
+ return null;
150
+ }
151
+ }
152
+
85
153
  // goalSuggestHandler — GET /goals/suggestions. Handler lives here, not inline in
86
154
  // routes/goals.js, because that file is over the fitness oversize threshold and
87
155
  // the ratchet is shrink-only; collaborators are injected so this module still
@@ -168,4 +236,4 @@ function criteriaSuggestHandler({ db, suggestCriteria, parseId }) {
168
236
  };
169
237
  }
170
238
 
171
- module.exports = { suggestGoals, goalSuggestHandler, criteriaSuggestHandler, SUGGEST_LIMIT };
239
+ module.exports = { suggestGoals, suggestGoalForCapture, goalSuggestHandler, criteriaSuggestHandler, SUGGEST_LIMIT };
@@ -18,6 +18,7 @@
18
18
 
19
19
  const db = require('./db');
20
20
  const classifier = require('./task-classifier');
21
+ const goalSuggest = require('./goal-suggest');
21
22
  const helpRequests = require('./help-requests');
22
23
  const taskRecommendations = require('./task-recommendations');
23
24
  const activity = require('./activity');
@@ -35,6 +36,12 @@ module.exports = {
35
36
  // (consumer: core cascade-dispatch.js, the dormant System-3 lib).
36
37
  get VOTABLE_TASK_STATUSES() { return classifier.VOTABLE_TASK_STATUSES; },
37
38
  classifyTaskKind: (...a) => classifier.classifyTaskKind(...a),
39
+ // suggestGoalForCapture: ONE goal for a filing that named none, or null (task
40
+ // 1003067). Consumer: the discord #bugs vector, which files through the ideas
41
+ // routing path and needs a home to offer it. Lives behind the port because
42
+ // "which goal, on which version" is a lifecycle judgement and discord may not
43
+ // import lifecycle (the one-way module rule).
44
+ suggestGoalForCapture: (...a) => goalSuggest.suggestGoalForCapture(...a),
38
45
 
39
46
  // --- work-tracking READS the staying-core read routes resolve --------------
40
47
  // public.js (the status surface): version/ship feed + grade trend.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.454",
3
+ "version": "1.19.456",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.454",
9
+ "version": "1.19.456",
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.454",
3
+ "version": "1.19.456",
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
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.454'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.456'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -3,7 +3,8 @@
3
3
  // Unit tests for Discord #bugs → GDS tasks (kind='bug') (Phase 3 F8, ADR 0047).
4
4
  // Pure screening/landing + orchestration via injected fakes — no Discord, no DB:
5
5
  // - screenBug: message → { ok, title, bodyMd }; blank/short/text-free rejected.
6
- // - decideBugLanding: Metic+ → 'ready'; everyone else linked → 'backlog'.
6
+ // - the landing: Metic+ → 'ready'; everyone else linked → 'backlog'. Decided by
7
+ // the ideas landing matrix since task 1003067, not by this module.
7
8
  // - recordInboundBug: files via createTask under the anti-abuse guard —
8
9
  // linked-only (unlinked rejected), rank-aware landing, per-builder
9
10
  // rate-limit, body screen; audits surface='discord_bot'; never throws.
@@ -23,7 +24,9 @@ function t(name, fn) {
23
24
  }
24
25
 
25
26
  const GOOD_ID = '123456789012345678';
26
- const CFG = { rateLimitPerHour: 5, versionId: 'GDS-V3', credits: 10 };
27
+ // task 1003067: versionId and credits are gone from this vector's config — the
28
+ // goal carries the version and the reward auto-assigns at the create layer.
29
+ const CFG = { rateLimitPerHour: 5 };
27
30
 
28
31
  console.log('Discord #bugs → GDS tasks (Phase 3 F8):');
29
32
 
@@ -51,21 +54,16 @@ await t('screenBug: long first line is capped (≤200, ellipsis)', () => {
51
54
  assert.ok(s.title.endsWith('…'));
52
55
  });
53
56
 
54
- // ---- decideBugLanding ------------------------------------------------------
55
- await t('decideBugLanding: Metic+ → ready', () => {
56
- assert.equal(bg.decideBugLanding('metic'), 'ready');
57
- assert.equal(bg.decideBugLanding('archon'), 'ready');
58
- assert.equal(bg.decideBugLanding('ARCHON'), 'ready');
59
- });
60
- await t('decideBugLanding: Xenos/Thetes/unknown → backlog', () => {
61
- for (const r of ['xenos', 'thetes', 'periokoi', '', null, undefined]) {
62
- assert.equal(bg.decideBugLanding(r), 'backlog');
63
- }
64
- });
65
57
 
66
58
  // ---- recordInboundBug (fakes) ---------------------------------------------
59
+ // The fake capture mirrors the real landing rule rather than hardcoding a status,
60
+ // so a matrix change shows up here as a failure instead of a stale fixture.
61
+ // task 1003067: the fake defers to the REAL landing matrix, so this fixture can
62
+ // never quietly disagree with the rule the server actually applies.
63
+ const { decideLanding } = require('../modules/ideas/routing.js');
64
+ const READY_OR_BACKLOG = (rank) => (rank == null ? null : decideLanding({ goalId: 7, kind: 'bug', filerRank: rank }).status);
67
65
  function makeDeps(over = {}) {
68
- const calls = { create: [], audit: [], count: [], persist: [] };
66
+ const calls = { create: [], audit: [], count: [], persist: [], suggest: [] };
69
67
  const deps = {
70
68
  getBuilderByDiscordUserId: async () => (over.builder === undefined ? null : over.builder),
71
69
  countRecentDiscordBugFilings: async (id, since) => {
@@ -73,7 +71,29 @@ function makeDeps(over = {}) {
73
71
  if (over.countThrows) throw new Error('count boom');
74
72
  return over.recent ?? 0;
75
73
  },
76
- createTask: async (a) => { calls.create.push(a); if (over.createThrows) throw new Error('db boom'); return { id: over.taskId ?? 555 }; },
74
+ // task 1003067: the vector files through ideas.capture now. `calls.create`
75
+ // keeps its name — it is still "what this vector asked to be created" — but
76
+ // the argument shape is a CAPTURE, and the landing status comes back from the
77
+ // matrix rather than being posted in.
78
+ suggestGoalForCapture: async (draft) => {
79
+ calls.suggest.push(draft);
80
+ if (over.suggestThrows) throw new Error('suggest boom');
81
+ return over.goal === undefined ? { id: 7, title: 'A goal', version_id: 'BONGOS-V1', score: 4 } : over.goal;
82
+ },
83
+ captureIdea: async (a) => {
84
+ calls.create.push(a);
85
+ if (over.createThrows) throw new Error('db boom');
86
+ // routeIdeaToGoal refuses a landing by THROWING, which rolls the idea row
87
+ // back with it — the case the goal-dropping retry exists for.
88
+ if (over.refuseRouted && a.goalId != null) throw { code: 'ROUTE_NOT_AUTHORIZED', gate: 'protected_scope' };
89
+ const routes = a.goalId != null && READY_OR_BACKLOG(a.filerRank) !== null;
90
+ return {
91
+ id: over.ideaId ?? 900,
92
+ routing: routes
93
+ ? { routed: true, task_id: over.taskId ?? 555, goal_id: a.goalId, status: READY_OR_BACKLOG(a.filerRank) }
94
+ : { routed: false, reason: 'no_goal' },
95
+ };
96
+ },
77
97
  insertAuditLog: async (a) => { calls.audit.push(a); if (over.auditThrows) throw new Error('audit boom'); return { id: 1 }; },
78
98
  persistImages: async (urls, opts) => {
79
99
  calls.persist.push({ urls, opts });
@@ -90,13 +110,16 @@ await t('recordInboundBug: linked Metic → filed at ready, kind=bug, attributed
90
110
  const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'Boats vanish at the dock', messageId: 'm1' }, deps, CFG);
91
111
  assert.equal(r.outcome, 'filed');
92
112
  assert.equal(r.taskId, 555);
113
+ assert.equal(r.ideaId, 900, 'the idea row is the attribution the old path threw away');
93
114
  assert.equal(r.status, 'ready');
94
- assert.equal(calls.create[0].kind, 'bug');
95
- assert.equal(calls.create[0].status, 'ready');
96
- assert.equal(calls.create[0].source, 'discord:bugs');
97
- assert.equal(calls.create[0].sourceRef, 'discord:m1');
98
- assert.equal(calls.create[0].versionId, 'GDS-V3');
99
- assert.match(calls.create[0].description, /reported from Discord #bugs by mona/);
115
+ assert.equal(calls.create[0].kind, 'bug', 'the channel IS the classification');
116
+ assert.equal(calls.create[0].filerRank, 'metic', 'the LIVE rank is handed to the matrix');
117
+ assert.equal(calls.create[0].capturedBy, 9, 'and the reporter is attached to the capture');
118
+ assert.equal(calls.create[0].goalId, 7, 'the suggester found it a home');
119
+ // task 1003067: no versionId is passed AT ALL. It used to be the hardcoded
120
+ // 'GDS-V3' — a shipped version, which is idea 1000771. The goal carries it now.
121
+ assert.equal(calls.create[0].versionId, undefined, 'this vector no longer names a version');
122
+ assert.match(calls.create[0].bodyMd, /reported from Discord #bugs by mona/);
100
123
  assert.equal(calls.audit[0].surface, 'discord_bot');
101
124
  assert.equal(calls.audit[0].responseStatus, 201);
102
125
  assert.equal(calls.audit[0].builderId, 9);
@@ -106,18 +129,74 @@ await t('recordInboundBug: linked Xenos → filed at backlog (needs confirm)', a
106
129
  const { deps, calls } = makeDeps({ builder: { id: 4, github_login: 'newbie', rank: 'xenos' } });
107
130
  const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'Typo on the welcome sign', messageId: 'm2' }, deps, CFG);
108
131
  assert.equal(r.outcome, 'filed');
109
- assert.equal(r.status, 'backlog');
110
- assert.equal(calls.create[0].status, 'backlog');
132
+ assert.equal(r.status, 'backlog', 'a sub-Metic report waits for a nod, as before');
133
+ assert.equal(calls.create[0].filerRank, 'xenos');
111
134
  });
112
135
 
113
- await t('recordInboundBug: UNLINKED author → ignored, nothing created', async () => {
136
+ await t('recordInboundBug: UNLINKED author → an inbox idea, never a task [changed by 1003067]', async () => {
137
+ // WAS: dropped entirely ('unlinked'), because this path minted claimable work
138
+ // and ADR 0047 would not let an unaccountable author near that. It no longer
139
+ // mints work, so the report is kept — as an idea row with no author and NO
140
+ // GOAL, which is exactly what #ideas has always done for the same person.
141
+ // The security property survives structurally: no goal ⇒ the matrix cannot
142
+ // route ⇒ no task exists to claim.
114
143
  const { deps, calls } = makeDeps({ builder: null });
115
144
  const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'something is broken here', messageId: 'm3' }, deps, CFG);
116
- assert.equal(r.outcome, 'ignored');
117
- assert.equal(r.reason, 'unlinked');
145
+ assert.equal(r.outcome, 'filed');
146
+ assert.equal(r.ideaId, 900, 'the report is recorded rather than silently dropped');
147
+ assert.equal(r.taskId, null, 'and it is NOT work');
148
+ assert.equal(r.status, null);
149
+ assert.equal(calls.create[0].capturedBy, null, 'unattributed, and honestly so');
150
+ assert.equal(calls.create[0].goalId, null, 'no home is offered to an unlinked author');
151
+ assert.equal(calls.suggest.length, 0, 'we do not even ask for one');
152
+ assert.match(calls.create[0].bodyMd, /unlinked author/);
153
+ });
154
+
155
+ await t('recordInboundBug: an unlinked author is NOT rate-limited by builder id (there is none)', async () => {
156
+ const { deps, calls } = makeDeps({ builder: null, recent: 999 });
157
+ const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'another unlinked report', messageId: 'm3b' }, deps, CFG);
158
+ assert.equal(r.outcome, 'filed', 'the per-builder cap cannot express an author with no builder row');
159
+ assert.equal(calls.count.length, 0, 'so it is not consulted');
160
+ assert.equal(r.taskId, null, 'the bound that matters is that none of it becomes work');
161
+ });
162
+
163
+ await t('recordInboundBug: a suggester failure still files — to the inbox [1003067]', async () => {
164
+ // A suggester that throws must not lose the bug report. No goal ⇒ inbox row.
165
+ const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, suggestThrows: true });
166
+ const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'the suggester is down', messageId: 'm3c' }, deps, CFG);
167
+ assert.equal(r.outcome, 'error', 'it surfaces as an error rather than pretending to have filed');
118
168
  assert.equal(calls.create.length, 0);
119
169
  });
120
170
 
171
+ await t('recordInboundBug: a REFUSED goal drops the guess and still files the report [1003067]', async () => {
172
+ // The suggester picks the home; the goal can still refuse it (protected scope, a
173
+ // scope-wall verdict, an archive mid-flight). Because routing is one transaction
174
+ // that refusal takes the idea row with it — so a goal WE guessed at would lose a
175
+ // report the reporter did nothing wrong to file. Drop the guess, keep the report.
176
+ const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, refuseRouted: true });
177
+ const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'a bug whose words match a protected goal', messageId: 'm7' }, deps, CFG);
178
+ assert.equal(r.outcome, 'filed', 'the report survives the refusal');
179
+ assert.equal(calls.create.length, 2, 'one routed attempt, then one homeless retry');
180
+ assert.equal(calls.create[0].goalId, 7, 'the first carried the suggested home');
181
+ assert.equal(calls.create[1].goalId, null, 'the retry drops it');
182
+ assert.equal(r.taskId, null, 'and it lands in the inbox, where triage will see it');
183
+ });
184
+
185
+ await t('recordInboundBug: a refusal with NO goal to drop is a real error, not a retry loop', async () => {
186
+ const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, goal: null, refuseRouted: true, createThrows: true });
187
+ const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'the database is simply down', messageId: 'm8' }, deps, CFG);
188
+ assert.equal(r.outcome, 'error', 'nothing to drop ⇒ surface it rather than retrying forever');
189
+ assert.equal(calls.create.length, 1, 'exactly one attempt');
190
+ });
191
+
192
+ await t('recordInboundBug: NO confident goal → the report still lands, in the inbox', async () => {
193
+ const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, goal: null });
194
+ const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'a bug matching no goal at all', messageId: 'm3d' }, deps, CFG);
195
+ assert.equal(r.outcome, 'filed');
196
+ assert.equal(r.taskId, null, 'no home ⇒ no task, which is the homeless-inbox contract (C2)');
197
+ assert.equal(calls.create[0].goalId, null);
198
+ });
199
+
121
200
  await t('recordInboundBug: over rate-limit → ignored + audited 429, nothing created', async () => {
122
201
  const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, recent: 5 });
123
202
  const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'yet another bug report', messageId: 'm4' }, deps, CFG);
@@ -170,7 +249,7 @@ await t('recordInboundBug: audit failure does NOT undo the filing', async () =>
170
249
  assert.equal(calls.create.length, 1);
171
250
  });
172
251
 
173
- await t('recordInboundBug: createTask error → {error}, never throws', async () => {
252
+ await t('recordInboundBug: a capture error → {error}, never throws', async () => {
174
253
  const { deps } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, createThrows: true });
175
254
  const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'valid bug body here', messageId: 'm' }, deps, CFG);
176
255
  assert.equal(r.outcome, 'error');
@@ -202,8 +281,8 @@ await t('recordInboundBug: text + images → persists images + embeds durable li
202
281
  assert.equal(r.images, 1);
203
282
  assert.equal(calls.persist.length, 1);
204
283
  assert.deepEqual(calls.persist[0].urls, ['https://cdn.discordapp.com/a.png']);
205
- assert.match(calls.create[0].description, /Attachments \(1\)/);
206
- assert.match(calls.create[0].description, /!\[attachment 1\]\(\/api\/gds\/bug-attachments\//);
284
+ assert.match(calls.create[0].bodyMd, /Attachments \(1\)/);
285
+ assert.match(calls.create[0].bodyMd, /!\[attachment 1\]\(\/api\/gds\/bug-attachments\//);
207
286
  });
208
287
 
209
288
  await t('recordInboundBug: image-only (empty text) → filed with screenshot title', async () => {
@@ -225,7 +304,7 @@ await t('recordInboundBug: image persist failure → still files (without images
225
304
  );
226
305
  assert.equal(r.outcome, 'filed');
227
306
  assert.equal(r.images, 0);
228
- assert.doesNotMatch(calls.create[0].description, /Attachments/);
307
+ assert.doesNotMatch(calls.create[0].bodyMd, /Attachments/);
229
308
  });
230
309
 
231
310
  await t('recordInboundBug: all images refused (stored empty) → filed, no Attachments block', async () => {
@@ -236,7 +315,7 @@ await t('recordInboundBug: all images refused (stored empty) → filed, no Attac
236
315
  );
237
316
  assert.equal(r.outcome, 'filed');
238
317
  assert.equal(r.images, 0);
239
- assert.doesNotMatch(calls.create[0].description, /Attachments/);
318
+ assert.doesNotMatch(calls.create[0].bodyMd, /Attachments/);
240
319
  });
241
320
 
242
321
  // ---- combineParts ----------------------------------------------------------
@@ -29,19 +29,21 @@
29
29
  // F10 make the rate-limit COUNT fail, to slip past the cap → denied (fail-closed)
30
30
  // F11 file through Discord #bugs at all, as a sub-Metic → 'backlog', never 'ready'
31
31
  // F12 file through Discord #ideas naming a goal → that vector cannot route
32
+ // F15 reach #bugs' old direct-createTask bypass → it no longer exists
33
+ // F16 file through #bugs as an UNLINKED author → an inbox row, never work
32
34
  // F13 declare touches[] outside the goal to widen blast area → the real wall refuses
33
35
  // F14 file against a SHIPPED version's goal → refused (version not building)
34
36
  // P1 a Metic+ files a bug in an ordinary goal → 'ready' (the C1 grant)
35
37
  // P2 a Metic+ files into protected scope → allowed, floored to metic
36
38
  //
37
39
  // AND THE ANTI-DRIFT PIN (the point of the file, really). The rows above are true
38
- // of the four capture vectors that exist TODAY. Nothing stops a fifth being added
40
+ // of the capture vectors that exist TODAY. Nothing stops a fifth being added
39
41
  // next month that quietly skips all of it — which is exactly how the 1000771 bug
40
42
  // got in. So the last section ENUMERATES the vectors from source and fails when
41
43
  // the roster changes, forcing a new vector's author to come here and add its row.
42
44
  //
43
45
  // PURE: the decision cores only (decideLanding, authorizeRoutedLanding,
44
- // decideBugLanding, rateLimitPerHour) — no DB, no network. The transaction that
46
+ // rateLimitPerHour) — no DB, no network. The transaction that
45
47
  // obeys them is proven in tests/idea_routing.mjs; the end-to-end attribution trail
46
48
  // in tests/idea_route_attribution.mjs.
47
49
  //
@@ -72,7 +74,7 @@ const read = (rel) => readFileSync(path.join(ROOT, rel), 'utf8');
72
74
  const routing = require('../modules/ideas/routing.js');
73
75
  const { decideLanding, authorizeRoutedLanding, routedPriority, PROTECTED_SCOPE_FLOOR } = routing;
74
76
  const bugs = require('../modules/discord/discord-bugs.js');
75
- const { decideBugLanding, rateLimitPerHour } = bugs;
77
+ const { rateLimitPerHour } = bugs;
76
78
  const { scopeIncludesProtected, isModuleKey } = require('../src/bongos/module-scope-map.js');
77
79
 
78
80
  const { test, summary } = makeRunner();
@@ -215,11 +217,15 @@ await test('[F10] a rate-limit COUNT failure DENIES — you cannot break the cou
215
217
  });
216
218
 
217
219
  await test('[F11] #bugs lands a sub-Metic at backlog and only Metic+ at ready', () => {
220
+ // Since R11 this is the SAME rule as F1/P1 — the channel has no landing logic of
221
+ // its own. Asserted through the matrix, which is now the only place it exists.
218
222
  for (const rank of SUB_METIC) {
219
- assert.equal(decideBugLanding(rank), 'backlog', `${JSON.stringify(rank)} must not reach ready`);
223
+ assert.equal(landing({ kind: 'bug', filerRank: rank }).status, 'backlog',
224
+ `${JSON.stringify(rank)} must not reach ready`);
220
225
  }
221
226
  for (const rank of TRUSTED) {
222
- assert.equal(decideBugLanding(rank), 'ready', `${rank} is the C1 straight-through grant`);
227
+ assert.equal(landing({ kind: 'bug', filerRank: rank }).status, 'ready',
228
+ `${rank} is the C1 straight-through grant`);
223
229
  }
224
230
  });
225
231
 
@@ -228,7 +234,7 @@ await test('[F9+F11] the flood, composed: at the cap, EVERY landed row is still
228
234
  const landedStatuses = [];
229
235
  for (let i = 0; i < cap + 25; i++) {
230
236
  if (i >= cap) continue; // the counter refuses past the cap (F9/F10)
231
- landedStatuses.push(decideBugLanding('xenos'));
237
+ landedStatuses.push(landing({ kind: 'bug', filerRank: 'xenos' }).status);
232
238
  }
233
239
  assert.equal(landedStatuses.length, cap, 'the cap bounds how many rows exist at all');
234
240
  assert.ok(landedStatuses.every((s) => s === 'backlog'),
@@ -297,6 +303,42 @@ await test('priority cannot be weaponised: an out-of-band suggestion is clamped,
297
303
  assert.equal(routedPriority(1), 1, 'a sane suggestion still rides through');
298
304
  });
299
305
 
306
+ // =========================================================================
307
+ // F15–F16 — the #bugs bypass is retired (task 1003067 / BV1.R11)
308
+ // =========================================================================
309
+
310
+ await test('[F15] #bugs mints NO task of its own — the bypass that lost attribution is gone', () => {
311
+ const src = read('modules/discord/discord-bugs.js');
312
+ const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
313
+ assert.ok(!/\bdeps\.createTask\(/.test(code),
314
+ 'the vector must file through ideas.capture, so every report leaves an idea row + lineage');
315
+ assert.match(code, /deps\.captureIdea\(/, 'and it does');
316
+ });
317
+
318
+ await test('[F15b] #bugs names NO version — a vector that cannot name one cannot name a dead one', () => {
319
+ const src = read('modules/discord/discord-bugs.js');
320
+ const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
321
+ // This is idea 1000771 in one assertion: the old default was 'GDS-V3', shipped,
322
+ // so every bug reported here landed in a closed version's catch-all.
323
+ assert.ok(!/GDS-V3|DEFAULT_BUGS_VERSION_ID|DISCORD_BUGS_VERSION_ID/.test(code),
324
+ 'no version id may be hardcoded or env-configured on this path any more');
325
+ assert.ok(!/versionId:/.test(code), 'and none is passed into the capture');
326
+ });
327
+
328
+ await test('[F16] an unlinked #bugs author gets an inbox row and CANNOT reach work', () => {
329
+ // The report is kept (it used to be dropped), but with no rank and no goal, so
330
+ // the matrix has nothing to route: structurally incapable of becoming a task.
331
+ const noRank = decideLanding({ goalId: null, kind: 'bug', filerRank: null });
332
+ assert.equal(noRank.route, false, 'no goal ⇒ the inbox');
333
+ // And even if a goal somehow reached it, an unknown filer never hard-lands:
334
+ const withGoal = decideLanding({ goalId: 5, kind: 'bug', filerRank: null });
335
+ assert.equal(withGoal.status, 'backlog', 'never claimable on an unknown filer');
336
+ const src = read('modules/discord/discord-bugs.js');
337
+ const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
338
+ assert.match(code, /if \(builder\) \{[\s\S]{0,200}suggestGoalForCapture/,
339
+ 'a goal is only ever sought for a LINKED builder');
340
+ });
341
+
300
342
  // =========================================================================
301
343
  // THE ANTI-DRIFT PIN — a fifth vector cannot arrive unaudited
302
344
  // =========================================================================
@@ -308,7 +350,7 @@ const KNOWN_FILING_VECTORS = {
308
350
  'modules/ideas/inbox.js': 'routeIdeaToGoal — the ONE transaction every routed filing runs (F1–F8, F13, F14)',
309
351
  'modules/ideas/routes/inbox.js': 'POST /inbox — the web + CLI door onto it (F3)',
310
352
  'modules/discord/discord-inbound.js': 'Discord #ideas — files an idea, never routes (F12)',
311
- 'modules/discord/discord-bugs.js': 'Discord #bugs — the direct-createTask bypass R11 retires (F9–F11)',
353
+ 'modules/discord/discord-bugs.js': 'Discord #bugs — files through ideas.capture since R11 (F9–F11, F15, F16)',
312
354
  };
313
355
 
314
356
  await test('[pin] every declared vector still reaches a task-minting path', () => {
@@ -336,20 +378,17 @@ await test('[pin] no OTHER module file mints a task from a filing without a row
336
378
  `a new filing vector must be added to KNOWN_FILING_VECTORS and given a matrix row: ${unaudited.join(', ')}`);
337
379
  });
338
380
 
339
- await test('[pin] the trusted tier is Metic+ in BOTH decision cores — one boundary, not two', () => {
340
- // routing.js and discord-bugs.js each spell the tier locally (both are pure and
341
- // import-free by design). They must agree, or "Metic+" means two things.
381
+ await test('[pin] there is ONE trusted-tier definition, and #bugs no longer keeps a copy', () => {
382
+ // This pin used to assert that two spellings of "Metic+" AGREED — routing.js and
383
+ // discord-bugs.js each carried one. R11 deleted the second, which is the better
384
+ // outcome: agreement you have to test for is a duplication you could have removed.
342
385
  const routingSrc = read('modules/ideas/routing.js');
343
- const bugsSrc = read('modules/discord/discord-bugs.js');
344
- assert.match(routingSrc, /METIC_PLUS = new Set\(\['metic', 'archon'\]\)/);
345
- assert.match(bugsSrc, /READY_RANKS = new Set\(\['metic', 'archon'\]\)/);
346
- // And behaviourally, which is what actually matters:
347
- for (const rank of [...SUB_METIC, ...TRUSTED]) {
348
- const viaRouting = landing({ kind: 'bug', filerRank: rank }).status;
349
- const viaBugs = decideBugLanding(rank);
350
- assert.equal(viaRouting, viaBugs,
351
- `the two vectors must agree on ${JSON.stringify(rank)} (routing=${viaRouting}, bugs=${viaBugs})`);
352
- }
386
+ assert.match(routingSrc, /METIC_PLUS = new Set\(\['metic', 'archon'\]\)/,
387
+ 'the one definition, in the matrix');
388
+ const bugsCode = read('modules/discord/discord-bugs.js')
389
+ .split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
390
+ assert.ok(!/READY_RANKS/.test(bugsCode), '#bugs must not reintroduce a local rank set');
391
+ assert.ok(!/decideBugLanding/.test(bugsCode), 'nor a local landing decision');
353
392
  });
354
393
 
355
394
  summary();
@@ -184,20 +184,26 @@ test('an unset subtitle is hidden; a set one is shown trimmed', () => {
184
184
  assert.equal(els.sub.textContent, 'the grep path');
185
185
  });
186
186
 
187
- test('on a LIST the subtitle is a tooltip, never a second visible line', () => {
187
+ test('on a LIST the subtitle is a visible line; on the CARD it stays a tooltip', () => {
188
188
  // task 1003206: the row/card painters moved to goals-render.js — the second half
189
189
  // of the carve goals-lib.js began — so read the family, not one filename.
190
190
  const js = goalsPageSource();
191
- // The helper lives in goals-lib.js — the file that exists because
191
+ // Both helpers live in goals-lib.js — the file that exists because
192
192
  // goals-page.js was over the excess-lines ratchet (task 1003135).
193
193
  const lib = read('modules', 'hall-ui', 'public', 'goals-lib.js');
194
194
  assert.match(lib, /function subtitleAttr/);
195
- assert.match(lib, /escapeHtml\(t\)/, 'escaped, since it lands in an attribute');
196
- for (const anchor of ['class="gr-title"', 'class="goal-card__title gr-title"']) {
197
- const i = js.indexOf(anchor);
198
- assert.ok(i > 0, `${anchor} still rendered`);
199
- assert.match(js.slice(i, i + 160), /subtitleAttr\(g\)/, `${anchor} carries the tooltip`);
200
- }
195
+ assert.match(lib, /function subtitleLine/);
196
+ assert.match(lib, /escapeHtml\(t\)/, 'escaped, since it lands in an attribute or a text node');
197
+ // task 1003307 gave the ROW its line back on the owner's call: 35 of 72 goals
198
+ // carry a subtitle, and a tooltip does not exist on a touch screen. The CARD
199
+ // has no room for a line, so it keeps the tooltip (task 1003224's shape).
200
+ const row = js.indexOf('class="gr-title"');
201
+ assert.ok(row > 0, 'the row title is still rendered');
202
+ assert.match(js.slice(row, row + 200), /subtitleLine\(g\)/, 'the row renders the visible line');
203
+ assert.doesNotMatch(js.slice(row, row + 160), /subtitleAttr\(g\)/, 'the row does not also carry the tooltip');
204
+ const card = js.indexOf('class="goal-card__title gr-title"');
205
+ assert.ok(card > 0, 'the card title is still rendered');
206
+ assert.match(js.slice(card, card + 160), /subtitleAttr\(g\)/, 'the card carries the tooltip');
201
207
  });
202
208
 
203
209
  test('every CSS token the new styles use actually exists', () => {