@bongos/core 1.20.37 → 1.20.39

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 (68) hide show
  1. package/.bongos-core.json +119 -64
  2. package/.claude/skills/backlog-review/SKILL.md +1 -1
  3. package/.claude/skills/bongos-feedback/SKILL.md +66 -0
  4. package/.claude/skills/collab-review/SKILL.md +1 -1
  5. package/.claude/skills/priority-session/SKILL.md +1 -1
  6. package/.claude/skills/session-handoff/SKILL.md +1 -1
  7. package/.claude/skills/status/SKILL.md +1 -1
  8. package/bin/bongos.js +1 -0
  9. package/clients/bongos-client/README.md +1 -1
  10. package/clients/bongos-client/bongos-client.global.js +8 -0
  11. package/clients/bongos-client/index.cjs +8 -0
  12. package/clients/bongos-client/index.d.ts +12 -1
  13. package/clients/bongos-client/index.mjs +8 -0
  14. package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
  15. package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +41 -0
  16. package/docs/adr/README.md +1 -0
  17. package/docs/api/openapi.json +188 -2
  18. package/docs/api-reference.md +12 -4
  19. package/docs/copy-inventory.md +10 -10
  20. package/docs/copy-registry.json +18 -18
  21. package/docs/file-map.md +1 -0
  22. package/docs/module-api-changelog.md +4 -0
  23. package/docs/page-readings.json +253 -250
  24. package/docs/recipes/upgrading-the-core.md +2 -0
  25. package/modules/autonomy/db.js +39 -8
  26. package/modules/autonomy/fence.js +26 -4
  27. package/modules/autonomy/migrations/autonomy_003_priority_goal.sql +32 -0
  28. package/modules/autonomy/routes/autonomy.js +28 -1
  29. package/modules/hall-ui/public/gate.js +33 -4
  30. package/modules/ideas/routing.js +8 -1
  31. package/modules/npm-release/preview/childlog.js +95 -0
  32. package/modules/npm-release/preview/commands.js +46 -5
  33. package/modules/npm-release/preview/env.js +18 -7
  34. package/modules/npm-release/preview/runtime.js +43 -9
  35. package/modules/npm-release/preview/supervisor.js +170 -31
  36. package/modules/npm-release/public/work.css +3 -0
  37. package/modules/npm-release/public/work.js +6 -1
  38. package/modules/npm-release/routes/preview.js +1 -0
  39. package/modules/platform-identity/instance-feedback.js +142 -0
  40. package/modules/platform-identity/module.json +1 -1
  41. package/modules/platform-identity/routes/sso.js +62 -0
  42. package/modules/provisioning/render-standup.js +13 -1
  43. package/modules/public-landing/public/projects.html +22 -8
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/release-notes.json +30 -0
  47. package/scripts/gds/autobongos-run.js +14 -4
  48. package/scripts/gds/feedback-send.js +131 -0
  49. package/scripts/gds/provision-render.js +13 -1
  50. package/scripts/gds/render-payload.js +6 -5
  51. package/scripts/gds/update-channel.js +82 -0
  52. package/src/bongos/routes/feedback.js +110 -0
  53. package/src/bongos/routes.js +2 -0
  54. package/src/module-api.js +1 -1
  55. package/tests/autobongos_fence.mjs +62 -0
  56. package/tests/autobongos_loop.mjs +76 -0
  57. package/tests/autonomy_fence_priority.mjs +211 -0
  58. package/tests/bongos_cli.mjs +7 -1
  59. package/tests/filing_abuse_matrix.mjs +7 -2
  60. package/tests/idea_routing.mjs +1 -1
  61. package/tests/instance_feedback.mjs +327 -0
  62. package/tests/npm_release_preview_commands.mjs +1 -1
  63. package/tests/npm_release_preview_diagnosis.mjs +376 -0
  64. package/tests/npm_release_preview_supervisor.mjs +4 -4
  65. package/tests/projects_hub_app_step.mjs +49 -0
  66. package/tests/projects_hub_render_connect.mjs +2 -1
  67. package/tests/provision_render.mjs +23 -2
  68. package/tests/update_channel_db.mjs +124 -0
@@ -260,3 +260,65 @@ test('no request means every allowlisted goal', () => {
260
260
  assert.deepEqual(allowedGoals(f, []).goals, [1, 2]);
261
261
  assert.deepEqual(allowedGoals(f).goals, [1, 2]);
262
262
  });
263
+
264
+ // ── the priority goal (task 1004453) ────────────────────────────────────────────
265
+ //
266
+ // Owner ruling: whoever starts the runner names the goal it works FIRST, as well as
267
+ // the set it may touch. The priority ORDERS the allowlist; it must never widen it.
268
+
269
+ test('the priority goal is read off the fence and moved to the front', () => {
270
+ const f = open({ goals: [{ goal_id: 1 }, { goal_id: 2 }, { goal_id: 3 }], priority_goal_id: 3 });
271
+ assert.equal(normaliseFence(f).priorityGoal, 3);
272
+ const d = decideRun({ fence: f, ...GRADER_OK });
273
+ assert.equal(d.go, true);
274
+ assert.deepEqual(d.goals, [3, 1, 2], 'the runner walks goals in this order, so the priority goes first');
275
+ assert.equal(d.priorityGoal, 3);
276
+ });
277
+
278
+ test('a priority that is not on the allowlist is ignored, never added', () => {
279
+ // The database refuses this with a foreign key; the pure half refuses it again,
280
+ // because a priority that could add a goal would be a second, unguarded allowlist.
281
+ const f = open({ goals: [{ goal_id: 1 }, { goal_id: 2 }], priority_goal_id: 99 });
282
+ assert.equal(normaliseFence(f).priorityGoal, null);
283
+ const d = decideRun({ fence: f, ...GRADER_OK });
284
+ assert.deepEqual(d.goals, [1, 2]);
285
+ assert.equal(d.priorityGoal, null);
286
+ });
287
+
288
+ test('a malformed or missing priority reads as "no priority", not as a refusal', () => {
289
+ for (const p of [undefined, null, '', 0, -4, 'abc', 1.5, {}]) {
290
+ const d = decideRun({ fence: open({ goals: [{ goal_id: 1 }, { goal_id: 2 }], priority_goal_id: p }), ...GRADER_OK });
291
+ assert.equal(d.go, true, `priority ${JSON.stringify(p)} must not stop the run`);
292
+ assert.deepEqual(d.goals, [1, 2], `priority ${JSON.stringify(p)} must not reorder`);
293
+ assert.equal(d.priorityGoal, null);
294
+ }
295
+ });
296
+
297
+ test('a stringified priority id (a bigint from the database) still counts', () => {
298
+ const d = decideRun({ fence: open({ goals: [{ goal_id: 1 }, { goal_id: 2 }], priority_goal_id: '2' }), ...GRADER_OK });
299
+ assert.deepEqual(d.goals, [2, 1]);
300
+ assert.equal(d.priorityGoal, 2);
301
+ });
302
+
303
+ test('narrowing on the command line keeps the priority first, and can drop it', () => {
304
+ const f = normaliseFence(open({ goals: [{ goal_id: 1 }, { goal_id: 2 }, { goal_id: 3 }], priority_goal_id: 3 }));
305
+ assert.deepEqual(allowedGoals(f, [1, 3]).goals, [3, 1]);
306
+ // An operator narrowing to goals that leave the priority out gets no priority —
307
+ // it is not quietly re-added behind their back.
308
+ assert.deepEqual(allowedGoals(f, [1, 2]).goals, [1, 2]);
309
+ const d = decideRun({ fence: open({ goals: [{ goal_id: 1 }, { goal_id: 2 }, { goal_id: 3 }], priority_goal_id: 3 }), requestedGoals: [1, 2], ...GRADER_OK });
310
+ assert.equal(d.priorityGoal, null, 'a row must not claim a pick came from a priority this run excluded');
311
+ });
312
+
313
+ test('prioritise only reorders: same members, same count', () => {
314
+ assert.deepEqual(fence.prioritise([5, 6, 7], 6), [6, 5, 7]);
315
+ assert.deepEqual(fence.prioritise([5, 6, 7], 8), [5, 6, 7]);
316
+ assert.deepEqual(fence.prioritise([5, 6, 7], null), [5, 6, 7]);
317
+ });
318
+
319
+ test('a closed fence still refuses whatever the priority says', () => {
320
+ const d = decideRun({ fence: open({ enabled: false, priority_goal_id: 1000119 }), ...GRADER_OK });
321
+ assert.equal(d.go, false);
322
+ assert.equal(d.code, 'kill_switch');
323
+ assert.deepEqual(d.goals, []);
324
+ });
@@ -1303,3 +1303,79 @@ test('past the uptime floor, a drift still exits', async () => {
1303
1303
  });
1304
1304
  assert.equal(code, runner.UPGRADE_EXIT_CODE);
1305
1305
  });
1306
+
1307
+ // ── the priority goal reaches the picker, the rows and the heartbeat (task 1004453) ──
1308
+
1309
+ const twoGoalFence = (priority) => async () => ({
1310
+ raw: { enabled: true, paused_reason: null, goals: [{ goal_id: 1000119 }, { goal_id: 1000090 }], priority_goal_id: priority },
1311
+ graderBypassed: false,
1312
+ });
1313
+
1314
+ test('the picker is handed the priority goal first', async () => {
1315
+ let sawGoals = null;
1316
+ const h = harness({
1317
+ readFence: twoGoalFence(1000090),
1318
+ pickTask: async (goals) => { sawGoals = goals; return { task: { ...aTask, goal_id: 1000090 }, goalId: 1000090, skipped: [] }; },
1319
+ });
1320
+ await runner.iteration({ ...opts, goals: [] }, h.deps);
1321
+ assert.deepEqual(sawGoals, [1000090, 1000119]);
1322
+ });
1323
+
1324
+ test('a worked row says its pick came from the priority goal', async () => {
1325
+ const h = harness({
1326
+ readFence: twoGoalFence(1000090),
1327
+ pickTask: async () => ({ task: { ...aTask, goal_id: 1000090 }, goalId: 1000090, skipped: [] }),
1328
+ });
1329
+ const row = await runner.iteration({ ...opts, goals: [] }, h.deps);
1330
+ assert.equal(row.event, 'worked');
1331
+ assert.equal(row.goal_id, 1000090);
1332
+ assert.equal(row.priority, true);
1333
+ });
1334
+
1335
+ test('once the priority goal runs dry, a pick from the rest reads priority:false', async () => {
1336
+ const h = harness({
1337
+ readFence: twoGoalFence(1000090),
1338
+ pickTask: async () => ({ task: aTask, goalId: 1000119, skipped: [] }),
1339
+ });
1340
+ const row = await runner.iteration({ ...opts, goals: [] }, h.deps);
1341
+ assert.equal(row.event, 'worked');
1342
+ assert.equal(row.goal_id, 1000119);
1343
+ assert.equal(row.priority, false);
1344
+ });
1345
+
1346
+ test('with no priority set, no pick claims to be one', async () => {
1347
+ const h = harness({ readFence: twoGoalFence(null) });
1348
+ const row = await runner.iteration({ ...opts, goals: [] }, h.deps);
1349
+ assert.equal(row.priority, false);
1350
+ });
1351
+
1352
+ test('a dry run and an empty queue both name the priority', async () => {
1353
+ const dry = await runner.iteration({ ...opts, goals: [], dryRun: true }, harness({
1354
+ readFence: twoGoalFence(1000119),
1355
+ }).deps);
1356
+ assert.equal(dry.event, 'dry_run');
1357
+ assert.equal(dry.priority, true);
1358
+ const none = await runner.iteration({ ...opts, goals: [] }, harness({
1359
+ readFence: twoGoalFence(1000119),
1360
+ pickTask: async () => ({ none: true, skipped: [] }),
1361
+ }).deps);
1362
+ assert.equal(none.event, 'nothing_claimable');
1363
+ assert.equal(none.priority_goal, 1000119);
1364
+ });
1365
+
1366
+ test('the server heartbeat carries the goal the current task came from', async () => {
1367
+ const beats = [];
1368
+ await runner.forever({ goals: [], maxTasks: 1, maxLoops: 1 }, {
1369
+ iteration: async () => ({ event: 'worked', task_id: 4242, goal_id: 1000090, priority: true }),
1370
+ reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true }),
1371
+ waitOrJump: async () => ({ waited: 0, jumped: false }),
1372
+ writeHeartbeat: () => true,
1373
+ postHeartbeat: async (fields) => { beats.push(fields); return true; },
1374
+ emit: () => {},
1375
+ record: (r) => r,
1376
+ codeDrifted: async () => false,
1377
+ });
1378
+ const b = beats.find((x) => x.working_task_id === 4242);
1379
+ assert.ok(b, 'the beat after a worked row must name its task');
1380
+ assert.equal(b.working_goal_id, 1000090);
1381
+ });
@@ -0,0 +1,211 @@
1
+ // tests/autonomy_fence_priority.mjs — the fence's priority goal, server side
2
+ // (task 1004453).
3
+ //
4
+ // Owner ruling, 2026-09-30: whoever starts the runner names the goal it works
5
+ // FIRST, from the hall's /builders/gate page or the API, and the server refuses a
6
+ // priority that is not on the allowlist. These drive REAL requests through the
7
+ // assembled autonomy router with only the auth gates stubbed (the
8
+ // routine_observability_api precedent), pin the SQL against a stubbed pool, and
9
+ // hold the migration to the shape the route relies on. The pure ordering rule is
10
+ // tests/autobongos_fence.mjs; the runner's use of it is tests/autobongos_loop.mjs.
11
+ //
12
+ // DB-free, no network. Run: node --test tests/autonomy_fence_priority.mjs
13
+
14
+ import { test } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { createRequire } from 'node:module';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const req = createRequire(import.meta.url);
23
+ const express = req('express');
24
+ const { attachFail } = req(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
25
+
26
+ const permStub = (key) => {
27
+ const mw = (_req, _res, next) => next();
28
+ mw.permKey = key;
29
+ return mw;
30
+ };
31
+ const passBuilder = (reqq, _res, next) => { reqq.builder = { id: 3, rank: 'archon' }; next(); };
32
+
33
+ function autonomyRouter() {
34
+ const api = req(path.join(ROOT, 'src/module-api.js'));
35
+ const saved = { b: api.requireBuilder, p: api.requirePermission };
36
+ api.requireBuilder = passBuilder;
37
+ api.requirePermission = permStub;
38
+ const routePath = req.resolve(path.join(ROOT, 'modules/autonomy/routes/autonomy.js'));
39
+ delete req.cache[routePath];
40
+ try {
41
+ return req(routePath)();
42
+ } finally {
43
+ api.requireBuilder = saved.b;
44
+ api.requirePermission = saved.p;
45
+ }
46
+ }
47
+
48
+ async function serve(router) {
49
+ const app = express();
50
+ app.use(express.json());
51
+ app.use(attachFail);
52
+ app.use(router);
53
+ const server = app.listen(0);
54
+ await new Promise((r) => server.once('listening', r));
55
+ return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() };
56
+ }
57
+
58
+ const post = (base, p, body) => fetch(`${base}${p}`, {
59
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
60
+ });
61
+
62
+ test('POST /autonomy/fence/priority sits behind the same owner-only atom as the allowlist', () => {
63
+ const router = autonomyRouter();
64
+ const layer = router.stack.find((l) => l.route && l.route.path === '/autonomy/fence/priority');
65
+ assert.ok(layer, 'the route is registered');
66
+ assert.equal(layer.route.methods.post, true);
67
+ assert.equal(layer.route.stack.length, 3, 'requireBuilder + requirePermission + handler');
68
+ assert.equal(layer.route.stack[1].handle.permKey, 'autonomy.fence.manage');
69
+ });
70
+
71
+ test('the route sets, clears, and refuses a goal that is not allowlisted', async () => {
72
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
73
+ const saved = db.setPriorityGoal;
74
+ const seen = [];
75
+ let answer = { row: { priority_goal_id: 1000119, updated_at: '2026-09-30T00:00:00.000Z' } };
76
+ db.setPriorityGoal = async (args) => { seen.push(args); return answer; };
77
+ const s = await serve(autonomyRouter());
78
+ try {
79
+ let r = await post(s.base, '/autonomy/fence/priority', { goal_id: 1000119 });
80
+ assert.equal(r.status, 200);
81
+ assert.equal((await r.json()).priority_goal_id, 1000119);
82
+ assert.deepEqual(seen.pop(), { goalId: 1000119, builderId: 3 }, 'the writer is the SESSION, never the body');
83
+
84
+ r = await post(s.base, '/autonomy/fence/priority', { goal_id: '1000119' });
85
+ assert.equal(r.status, 200, 'a stringified id is accepted, as the allowlist route accepts one');
86
+ assert.equal(seen.pop().goalId, 1000119);
87
+
88
+ answer = { row: { priority_goal_id: null, updated_at: '2026-09-30T00:00:00.000Z' } };
89
+ r = await post(s.base, '/autonomy/fence/priority', { goal_id: null });
90
+ assert.equal(r.status, 200);
91
+ assert.equal(seen.pop().goalId, null, 'null clears the priority');
92
+
93
+ answer = { notAllowed: true };
94
+ r = await post(s.base, '/autonomy/fence/priority', { goal_id: 1000090 });
95
+ assert.equal(r.status, 409, 'a priority must already be on the allowlist');
96
+ assert.equal((await r.json()).error.code, 'goal_not_allowlisted');
97
+
98
+ answer = { missing: true };
99
+ r = await post(s.base, '/autonomy/fence/priority', { goal_id: 1000119 });
100
+ assert.equal(r.status, 503);
101
+
102
+ const before = seen.length;
103
+ for (const bad of [{}, { goal_id: 0 }, { goal_id: -1 }, { goal_id: 'abc' }, { goal_id: 1.5 }]) {
104
+ r = await post(s.base, '/autonomy/fence/priority', bad);
105
+ assert.equal(r.status, 400, `${JSON.stringify(bad)} is refused`);
106
+ }
107
+ assert.equal(seen.length, before, 'a malformed request never reaches the database');
108
+
109
+ db.setPriorityGoal = async () => { throw new Error('connection reset'); };
110
+ r = await post(s.base, '/autonomy/fence/priority', { goal_id: 1000119 });
111
+ assert.equal(r.status, 500);
112
+ assert.ok(!JSON.stringify(await r.json()).includes('connection reset'), 'the database error stays in the log');
113
+ } finally {
114
+ s.close();
115
+ db.setPriorityGoal = saved;
116
+ }
117
+ });
118
+
119
+ test('setPriorityGoal: the WHERE refuses a goal that is not allowlisted, and says which refusal it was', async () => {
120
+ const api = req(path.join(ROOT, 'src/module-api.js'));
121
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
122
+ const saved = api.pool.query;
123
+ const calls = [];
124
+ let replies = [];
125
+ api.pool.query = async (sql, params) => { calls.push({ sql, params }); return replies.shift(); };
126
+ try {
127
+ replies = [{ rows: [{ priority_goal_id: '1000119', updated_at: 't' }] }];
128
+ assert.deepEqual(await db.setPriorityGoal({ goalId: 1000119, builderId: 3 }), { row: { priority_goal_id: 1000119, updated_at: 't' } });
129
+ assert.match(calls[0].sql, /UPDATE autonomy_fence/);
130
+ assert.match(calls[0].sql, /EXISTS \(SELECT 1 FROM autonomy_allowed_goals WHERE goal_id = \$1::bigint\)/);
131
+ assert.deepEqual(calls[0].params, [1000119, 3]);
132
+
133
+ replies = [{ rows: [] }, { rows: [{ '?column?': 1 }] }];
134
+ assert.deepEqual(await db.setPriorityGoal({ goalId: 1000090 }), { notAllowed: true });
135
+
136
+ replies = [{ rows: [] }, { rows: [] }];
137
+ assert.deepEqual(await db.setPriorityGoal({ goalId: 1000090 }), { missing: true });
138
+
139
+ replies = [{ rows: [{ priority_goal_id: null, updated_at: 't' }] }];
140
+ assert.deepEqual(await db.setPriorityGoal({ goalId: null }), { row: { priority_goal_id: null, updated_at: 't' } });
141
+ } finally {
142
+ api.pool.query = saved;
143
+ }
144
+ });
145
+
146
+ test('readFence carries the priority as a number, and null when unset', async () => {
147
+ const api = req(path.join(ROOT, 'src/module-api.js'));
148
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
149
+ const saved = api.pool.query;
150
+ let pri = '1000119';
151
+ api.pool.query = async (sql) => (/FROM autonomy_fence/.test(sql)
152
+ ? { rows: [{ enabled: true, paused_reason: null, priority_goal_id: pri, updated_at: 't', updated_by: '3' }] }
153
+ : { rows: [{ goal_id: '1000119', note: null, added_at: 't', added_by: '3' }] });
154
+ try {
155
+ assert.equal((await db.readFence()).priority_goal_id, 1000119, 'a bigint string would never equal a numeric goal id');
156
+ pri = null;
157
+ assert.equal((await db.readFence()).priority_goal_id, null);
158
+ } finally {
159
+ api.pool.query = saved;
160
+ }
161
+ });
162
+
163
+ test('the heartbeat stores and returns the goal the current task came from', async () => {
164
+ const api = req(path.join(ROOT, 'src/module-api.js'));
165
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
166
+ const saved = api.pool.query;
167
+ const calls = [];
168
+ api.pool.query = async (sql, params) => { calls.push({ sql, params }); return { rows: [{ working_task_id: 4242, working_goal_id: 1000090 }] }; };
169
+ try {
170
+ await db.recordRunnerHeartbeat({ builderId: 3, host: 'winbox', workingTaskId: 4242, workingGoalId: 1000090 });
171
+ assert.match(calls[0].sql, /working_goal_id = EXCLUDED\.working_goal_id/);
172
+ assert.equal(calls[0].params[8], 1000090);
173
+ const rows = await db.readRunnerHeartbeats(3);
174
+ assert.equal(rows[0].working_goal_id, '1000090');
175
+ } finally {
176
+ api.pool.query = saved;
177
+ }
178
+ });
179
+
180
+ test('the route accepts working_goal_id on a heartbeat and passes it through', async () => {
181
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
182
+ const saved = db.recordRunnerHeartbeat;
183
+ const seen = [];
184
+ db.recordRunnerHeartbeat = async (args) => { seen.push(args); return {}; };
185
+ const s = await serve(autonomyRouter());
186
+ try {
187
+ const r = await post(s.base, '/autonomy/heartbeat', { host: 'winbox', working_task_id: 4242, working_goal_id: 1000090 });
188
+ assert.equal(r.status, 200);
189
+ assert.equal(seen[0].workingGoalId, 1000090);
190
+ } finally {
191
+ s.close();
192
+ db.recordRunnerHeartbeat = saved;
193
+ }
194
+ });
195
+
196
+ test('the migration ties the priority to the allowlist and clears it on revoke', () => {
197
+ const sql = fs.readFileSync(path.join(ROOT, 'modules/autonomy/migrations/autonomy_003_priority_goal.sql'), 'utf8')
198
+ .split('\n').filter((l) => !l.trim().startsWith('--')).join('\n');
199
+ assert.match(sql, /ADD COLUMN IF NOT EXISTS priority_goal_id bigint\s+REFERENCES autonomy_allowed_goals \(goal_id\) ON DELETE SET NULL/,
200
+ 'revoking the priority goal must clear the priority, never leave a revoked goal "first"');
201
+ assert.match(sql, /ADD COLUMN IF NOT EXISTS working_goal_id bigint/);
202
+ assert.doesNotMatch(sql, /\bDROP\b/i, 'module migrations are additive');
203
+ });
204
+
205
+ test('the gate page renders the priority, lists it first, and posts to the priority route', () => {
206
+ const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
207
+ assert.match(src, /renderFenceGoals\(fence\.goals, fence\.priority_goal_id\)/, 'the page must pass the priority it read');
208
+ assert.match(src, /sendJson\('POST', '\/autonomy\/fence\/priority', \{ goal_id: goalId \}\)/);
209
+ assert.match(src, /Make priority/);
210
+ assert.match(src, /Clear priority/);
211
+ });
@@ -87,7 +87,13 @@ test('VERBS: selfHelp is only claimed by scripts that really print their own usa
87
87
  // `serve` is dev.js with --server pinned on, so it inherits dev's own usage()
88
88
  // printer — verified: `bongos serve --help` prints the card and exits 0.
89
89
  const selfHelp = Object.entries(cli.VERBS).filter(([, d]) => d.selfHelp).map(([v]) => v).sort();
90
- assert.deepEqual(selfHelp, ['dev', 'module', 'serve', 'status', 'task', 'upgrade']);
90
+ assert.deepEqual(selfHelp, ['dev', 'feedback', 'module', 'serve', 'status', 'task', 'upgrade']);
91
+ });
92
+
93
+ test('bongos feedback --help prints its own usage and sends nothing', () => {
94
+ const r = run('feedback', '--help');
95
+ assert.equal(r.status, 0, r.stderr);
96
+ assert.match(r.stdout, /bongos feedback "what went wrong"/);
91
97
  });
92
98
 
93
99
  // ---- spawned: the dispatcher answers --help without touching the script ----
@@ -405,10 +405,15 @@ await test('[F18] the vector fails CLOSED — forged, absent or unknown never op
405
405
  `vector ${JSON.stringify(vector)} must not route`
406
406
  );
407
407
  }
408
- // The allowlist is exactly two members, and both are server-set at their call
408
+ // The allowlist is exactly three members, and all are server-set at their call
409
409
  // site — never read from a request body, which is what makes forging it a
410
410
  // non-move rather than a merely-hard one.
411
- assert.deepEqual([...routing.HOMELESS_ROUTING_VECTORS].sort(), ['api', 'discord-bugs']);
411
+ assert.deepEqual([...routing.HOMELESS_ROUTING_VECTORS].sort(), ['api', 'discord-bugs', 'instance-feedback']);
412
+ // The hub's feedback relay (task 1004462) names its own vector and never a rank,
413
+ // so a relayed bug can only ever land at 'backlog'.
414
+ const feedbackSrc = read('modules/platform-identity/instance-feedback.js');
415
+ assert.match(feedbackSrc, /vector: 'instance-feedback'/, 'the relay hardcodes its own vector');
416
+ assert.match(feedbackSrc, /filerRank: null/, 'and files with no rank');
412
417
  const routeSrc = read('modules/ideas/routes/inbox.js');
413
418
  assert.match(routeSrc, /vector: 'api'/, 'the route hardcodes its own vector');
414
419
  assert.ok(!/vector:\s*body\.|vector:\s*req\.body/.test(routeSrc),
@@ -172,7 +172,7 @@ await test('matrix: FAIL-CLOSED — an absent or unrecognised rank never hard-la
172
172
  // -------------------------------------------------------------------------
173
173
 
174
174
  await test('matrix: a homeless BUG from a trusted vector routes, and keeps the rank split', () => {
175
- for (const vector of ['api', 'discord-bugs']) {
175
+ for (const vector of ['api', 'discord-bugs', 'instance-feedback']) {
176
176
  assert.deepEqual(
177
177
  decideLanding({ goalId: null, kind: 'bug', filerRank: 'archon', vector }),
178
178
  { route: true, status: 'ready', homeless: true, reason: 'bug_homeless_trusted_filer' },