@bongos/core 1.19.639 → 1.19.640
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +16 -16
- package/docs/module-api-changelog.md +2 -0
- package/modules/builder-settings/builder-needs.js +6 -3
- package/modules/discord/board-broadcast.js +10 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/sequence.js +111 -3
- package/src/module-api.js +1 -1
- package/tests/discord_board_broadcast.mjs +4 -2
- package/tests/government_board_hash_redirect.mjs +60 -0
- package/tests/government_board_summons.mjs +8 -4
- package/tests/sequence.mjs +157 -0
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.640",
|
|
6
|
+
"core_contract": "1.19.640",
|
|
7
|
+
"source_commit": "2ab73a01133b60e930ce77745cd7bab2be53d517",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-10T17:05:44.826Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 470,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 2598,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "5d4e0f8138d98ccd04fe0ae74d565738470c6b6210f6ccad1084e067ed8eab63",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/backlog-review/SKILL.md",
|
|
@@ -2772,7 +2772,7 @@
|
|
|
2772
2772
|
{
|
|
2773
2773
|
"path": "docs/module-api-changelog.md",
|
|
2774
2774
|
"mode": "0000644",
|
|
2775
|
-
"sha256": "
|
|
2775
|
+
"sha256": "27b63233558ace04674687e72734ac62bf14b5ff1bc8f0f218ace0b708121a82"
|
|
2776
2776
|
},
|
|
2777
2777
|
{
|
|
2778
2778
|
"path": "docs/modules-contract.md",
|
|
@@ -3867,7 +3867,7 @@
|
|
|
3867
3867
|
{
|
|
3868
3868
|
"path": "modules/builder-settings/builder-needs.js",
|
|
3869
3869
|
"mode": "0000644",
|
|
3870
|
-
"sha256": "
|
|
3870
|
+
"sha256": "fb576eb616b62b6616ee4875745f062ff01c8be2c31e88b11cf058bee3a6b71a"
|
|
3871
3871
|
},
|
|
3872
3872
|
{
|
|
3873
3873
|
"path": "modules/builder-settings/db.js",
|
|
@@ -4267,7 +4267,7 @@
|
|
|
4267
4267
|
{
|
|
4268
4268
|
"path": "modules/discord/board-broadcast.js",
|
|
4269
4269
|
"mode": "0000644",
|
|
4270
|
-
"sha256": "
|
|
4270
|
+
"sha256": "1d3cfd6ef66c5dfb40d8e39cad9a2683d0a7cfd692c6da85f0a008008bfd4185"
|
|
4271
4271
|
},
|
|
4272
4272
|
{
|
|
4273
4273
|
"path": "modules/discord/bug-attachments.js",
|
|
@@ -7717,12 +7717,12 @@
|
|
|
7717
7717
|
{
|
|
7718
7718
|
"path": "package-lock.json",
|
|
7719
7719
|
"mode": "0000644",
|
|
7720
|
-
"sha256": "
|
|
7720
|
+
"sha256": "cd5ac6c00223a78f16aae563bafeadb8d919a15eb46aa70a955b31f1e4a4d14b"
|
|
7721
7721
|
},
|
|
7722
7722
|
{
|
|
7723
7723
|
"path": "package.json",
|
|
7724
7724
|
"mode": "0000644",
|
|
7725
|
-
"sha256": "
|
|
7725
|
+
"sha256": "3220018d398311cd5dad8ad43c895021eb38edcfa607bcbd72d16afae8323a6f"
|
|
7726
7726
|
},
|
|
7727
7727
|
{
|
|
7728
7728
|
"path": "public-docs/index.html",
|
|
@@ -8802,7 +8802,7 @@
|
|
|
8802
8802
|
{
|
|
8803
8803
|
"path": "scripts/gds/sequence.js",
|
|
8804
8804
|
"mode": "0000644",
|
|
8805
|
-
"sha256": "
|
|
8805
|
+
"sha256": "966e2caee9fff17d57d523402af8f39b02572270b1f4437377eb4b64a8bb494d"
|
|
8806
8806
|
},
|
|
8807
8807
|
{
|
|
8808
8808
|
"path": "scripts/gds/session-cost.js",
|
|
@@ -9477,7 +9477,7 @@
|
|
|
9477
9477
|
{
|
|
9478
9478
|
"path": "src/module-api.js",
|
|
9479
9479
|
"mode": "0000644",
|
|
9480
|
-
"sha256": "
|
|
9480
|
+
"sha256": "ae0011557d1a9e04d4193538369dfc2c1cad08dfcefa0898c904566fd2374fe5"
|
|
9481
9481
|
},
|
|
9482
9482
|
{
|
|
9483
9483
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10362,7 +10362,7 @@
|
|
|
10362
10362
|
{
|
|
10363
10363
|
"path": "tests/discord_board_broadcast.mjs",
|
|
10364
10364
|
"mode": "0000644",
|
|
10365
|
-
"sha256": "
|
|
10365
|
+
"sha256": "df417a2907c9436d2645dc63121ddb7b7152bf9d3aeb0adeffcc67febb7e43ef"
|
|
10366
10366
|
},
|
|
10367
10367
|
{
|
|
10368
10368
|
"path": "tests/discord_bot.mjs",
|
|
@@ -10757,7 +10757,7 @@
|
|
|
10757
10757
|
{
|
|
10758
10758
|
"path": "tests/government_board_hash_redirect.mjs",
|
|
10759
10759
|
"mode": "0000644",
|
|
10760
|
-
"sha256": "
|
|
10760
|
+
"sha256": "8dbd98e30dc2ece5896a2f412f6ad8a677ee37025f66f3e21c92b90da5bb192d"
|
|
10761
10761
|
},
|
|
10762
10762
|
{
|
|
10763
10763
|
"path": "tests/government_board_majority.mjs",
|
|
@@ -10782,7 +10782,7 @@
|
|
|
10782
10782
|
{
|
|
10783
10783
|
"path": "tests/government_board_summons.mjs",
|
|
10784
10784
|
"mode": "0000644",
|
|
10785
|
-
"sha256": "
|
|
10785
|
+
"sha256": "2fc96deb1fb0521befd18b8f2c569076f0047ffe8da49a8129a24b5eb8fd0fcf"
|
|
10786
10786
|
},
|
|
10787
10787
|
{
|
|
10788
10788
|
"path": "tests/government_board_view.mjs",
|
|
@@ -12362,7 +12362,7 @@
|
|
|
12362
12362
|
{
|
|
12363
12363
|
"path": "tests/sequence.mjs",
|
|
12364
12364
|
"mode": "0000644",
|
|
12365
|
-
"sha256": "
|
|
12365
|
+
"sha256": "5f5933b629b39b90ce3a3ebb5e44b42d573dcb58985716fdcd1a50190754e7a5"
|
|
12366
12366
|
},
|
|
12367
12367
|
{
|
|
12368
12368
|
"path": "tests/served_paths_shipped.mjs",
|
|
@@ -1727,5 +1727,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1727
1727
|
landed since 1.19.637 with no explicit bump. run 34438897721. (task 1002620)
|
|
1728
1728
|
1.19.639 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1729
1729
|
landed since 1.19.638 with no explicit bump. run 34502609272. (task 1002620)
|
|
1730
|
+
1.19.640 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1731
|
+
landed since 1.19.639 with no explicit bump. run 34506041899. (task 1002620)
|
|
1730
1732
|
---------------------------------------------------------------------------
|
|
1731
1733
|
```
|
|
@@ -174,10 +174,13 @@ function boardVotesNeed(ctx) {
|
|
|
174
174
|
if (n <= 0) return null;
|
|
175
175
|
const firstId = pending && pending.first_item_id;
|
|
176
176
|
// Deep-link the named sitting when we know it (?item=<id> — the Board Room
|
|
177
|
-
//
|
|
177
|
+
// page scrolls to and highlights it), else the room itself. The Board Room is
|
|
178
|
+
// its own page since ADR 0266; the retired hash form it replaced is a fragment
|
|
179
|
+
// the server never sees, so it survives only as government.js's client-side
|
|
180
|
+
// redirect and nothing here emits it any more.
|
|
178
181
|
const href = firstId
|
|
179
|
-
? `/
|
|
180
|
-
: '/
|
|
182
|
+
? `/board-room?item=${encodeURIComponent(firstId)}`
|
|
183
|
+
: '/board-room';
|
|
181
184
|
const subject = n === 1 ? 'An idea is' : `${n} ideas are`;
|
|
182
185
|
return {
|
|
183
186
|
id: 'board_votes',
|
|
@@ -151,11 +151,18 @@ function subjectLabel(subjectType) {
|
|
|
151
151
|
return subjectType === 'constitutional_amendment' ? 'A constitutional amendment' : 'A Full Idea';
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
// Straight to THAT item: ?item=<id> — the Board Room
|
|
155
|
-
// to / highlights the named sitting
|
|
154
|
+
// Straight to THAT item: ?item=<id> — the Board Room page reads it and scrolls
|
|
155
|
+
// to / highlights the named sitting.
|
|
156
|
+
//
|
|
157
|
+
// POINTS AT `/board-room`, the room's own page since ADR 0266. This is the link
|
|
158
|
+
// a member is most likely to click (ADR 0175 §8 makes the mention the "vote
|
|
159
|
+
// within X" notice), and it is also the one that outlives the code: an
|
|
160
|
+
// announcement already in channel history keeps whatever URL it was posted with
|
|
161
|
+
// forever. That is why government.js's `#board-room` redirect stays a release
|
|
162
|
+
// regardless — it serves the old messages, not this writer.
|
|
156
163
|
function itemLink(itemId) {
|
|
157
164
|
const origin = ((api.branding && api.branding().domains) || {}).buildersOrigin || '';
|
|
158
|
-
return `${origin ? origin.replace(/\/$/, '') : ''}/
|
|
165
|
+
return `${origin ? origin.replace(/\/$/, '') : ''}/board-room?item=${encodeURIComponent(itemId)}`;
|
|
159
166
|
}
|
|
160
167
|
|
|
161
168
|
// ── the close (BV1.R22 of goal 1000069) ──────────────────────────────────────
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.640",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.640",
|
|
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.
|
|
3
|
+
"version": "1.19.640",
|
|
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/scripts/gds/sequence.js
CHANGED
|
@@ -164,13 +164,62 @@ function chainFrom(headId, byId, { limit = DEFAULT_LIMIT } = {}) {
|
|
|
164
164
|
return picked;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
// Does the task's OWN declared file surface rule a schema change out? (task 1003699)
|
|
168
|
+
//
|
|
169
|
+
// tasks.needs_migration is set server-side from a keyword scan of the title and
|
|
170
|
+
// description (migration 081; detectsMigration in
|
|
171
|
+
// modules/lifecycle/routes/task-route-helpers.js), so the flag means "this task
|
|
172
|
+
// SAYS the word migration", not "this task changes the schema". Task 1003697
|
|
173
|
+
// carried it while its description said, in capitals, not to change any
|
|
174
|
+
// migration; the row for THIS task is flagged too, for the same reason. A false
|
|
175
|
+
// halt is not free — it is a task the runner will never offer.
|
|
176
|
+
//
|
|
177
|
+
// So the flag alone no longer halts. It is overridden only by positive evidence
|
|
178
|
+
// pointing the other way: a NON-EMPTY touches[] that names no path under
|
|
179
|
+
// migrations/. That is the author stating the file surface and leaving
|
|
180
|
+
// migrations/ out of it.
|
|
181
|
+
//
|
|
182
|
+
// The asymmetry is deliberate. An absent or empty touches[] tells us nothing, so
|
|
183
|
+
// it keeps halting — and it must, because the rank floor cannot be leaned on
|
|
184
|
+
// here: task 1003697 sat at requires_rank 'xenos' with the flag set, so ADR
|
|
185
|
+
// 0270's needs_migration→floor coupling did not raise it and would not have
|
|
186
|
+
// caught a real schema change either.
|
|
187
|
+
//
|
|
188
|
+
// Note this is NARROWER than what the task proposed. "touches[] hitting
|
|
189
|
+
// migrations/, or a declared migration number" would have re-read the prose, and
|
|
190
|
+
// 1003697's description contains a literal migrations/… path while its touches[]
|
|
191
|
+
// contains none — so the number/path branch would have preserved the very false
|
|
192
|
+
// halt the task was filed to remove, and failed its own acceptance criterion.
|
|
193
|
+
// touches[] is the only signal here that is structural rather than vocabulary.
|
|
194
|
+
// A `migrations` DIRECTORY anywhere in the path, not only at the repo root.
|
|
195
|
+
// This codebase ships MODULE-OWNED schema migrations at
|
|
196
|
+
// modules/<key>/migrations/NNN_*.sql (ADR 0083; docs/modules-contract.md §
|
|
197
|
+
// "Module-owned migrations"), and ten such directories exist today — so an
|
|
198
|
+
// anchored /^migrations\// test would have called every one of them "not a
|
|
199
|
+
// migration" and suppressed the halt on a real schema change.
|
|
200
|
+
//
|
|
201
|
+
// That mistake would have been worse than the bug this task fixes, because
|
|
202
|
+
// nothing else catches it: permission-path-check's protected-surfaces registry
|
|
203
|
+
// carries a `migrations/` glob that IS a second gate for root migrations, but it
|
|
204
|
+
// is root-anchored too, so a module-owned migration has no other halt at all.
|
|
205
|
+
//
|
|
206
|
+
// Matching a whole path SEGMENT is what keeps docs/migrations-guide.md — a file
|
|
207
|
+
// with the word in its name and no migrations directory — from counting.
|
|
208
|
+
const MIGRATION_DIR_RE = /(?:^|\/)migrations\//i;
|
|
209
|
+
|
|
210
|
+
function declaredSurfaceExcludesMigrations(task) {
|
|
211
|
+
const touches = Array.isArray(task.touches) ? task.touches : [];
|
|
212
|
+
if (!touches.length) return false;
|
|
213
|
+
return !touches.some((raw) => MIGRATION_DIR_RE.test(String(raw).trim().replace(/\\/g, '/')));
|
|
214
|
+
}
|
|
215
|
+
|
|
167
216
|
// The halt matrix. Facts only — every entry is something the runner can read
|
|
168
217
|
// off the task row, never a judgement about the work itself.
|
|
169
218
|
function haltReasons(task, { rank, claimableIds = null } = {}) {
|
|
170
219
|
const reasons = [];
|
|
171
220
|
if (!task) return [{ code: 'not_found', detail: 'no such task' }];
|
|
172
221
|
|
|
173
|
-
if (task.needs_migration) {
|
|
222
|
+
if (task.needs_migration && !declaredSurfaceExcludesMigrations(task)) {
|
|
174
223
|
reasons.push({ code: 'needs_migration', detail: HALT_REASONS.needs_migration });
|
|
175
224
|
}
|
|
176
225
|
if (task.security_sensitive) {
|
|
@@ -286,9 +335,64 @@ async function fetchChain(api, headId, limit) {
|
|
|
286
335
|
return byId;
|
|
287
336
|
}
|
|
288
337
|
|
|
338
|
+
// GOAL_PAGE is deliberately below the route's limit cap (1000): the cap is what
|
|
339
|
+
// made the old single read look complete, and paging in smaller bites means the
|
|
340
|
+
// "did I get everything?" question is answered by a short last page rather than
|
|
341
|
+
// by trusting one number. GOAL_MAX_ROWS is a runaway guard, not a window — it is
|
|
342
|
+
// far above any real goal, and hitting it throws rather than truncating.
|
|
343
|
+
const GOAL_PAGE = 500;
|
|
344
|
+
const GOAL_MAX_ROWS = 20000;
|
|
345
|
+
|
|
346
|
+
// Every task row in one goal, paged until the server runs out (task 1003699).
|
|
347
|
+
//
|
|
348
|
+
// THE BUG THIS REPLACES. The old body asked for `?limit=1000` with NO goal
|
|
349
|
+
// filter and matched goal_id in JS. Past a thousand rows in that ordering — this
|
|
350
|
+
// instance crossed it long ago — a task in the goal simply fell outside the
|
|
351
|
+
// window, and the planner printed "No open tasks in goal N". That is
|
|
352
|
+
// indistinguishable from a finished chain, which is the dangerous part: the
|
|
353
|
+
// runner concludes the goal is DONE and stops. Raising the limit only moves the
|
|
354
|
+
// cliff, so the fix is to filter server-side (?goal_id=, ADR 0086 §3) and page.
|
|
355
|
+
//
|
|
356
|
+
// ANY unreadable page throws, for the same reason the bug was invisible: "I
|
|
357
|
+
// could not look" and "there is nothing left" must never render as the same
|
|
358
|
+
// sentence — and a dropped middle page would say the second while meaning the
|
|
359
|
+
// first.
|
|
360
|
+
async function fetchGoalTaskRows(api, goalId) {
|
|
361
|
+
const rows = [];
|
|
362
|
+
for (let offset = 0; ; offset += GOAL_PAGE) {
|
|
363
|
+
const query = { goal_id: Number(goalId), limit: GOAL_PAGE };
|
|
364
|
+
// The route rejects offset=0 as "must be a positive integer", so page one
|
|
365
|
+
// sends no offset at all.
|
|
366
|
+
if (offset) query.offset = offset;
|
|
367
|
+
const r = await api.tasks.getTasks({ query });
|
|
368
|
+
if (!r.ok) {
|
|
369
|
+
// ANY failed page throws, not just the first. Returning the rows read so
|
|
370
|
+
// far would hand back a short list that looks like a complete goal — the
|
|
371
|
+
// same "silent incompleteness reads as done" mistake this task exists to
|
|
372
|
+
// remove, just relocated from a fixed window to a transient failure. A
|
|
373
|
+
// caller that wants partial data can ask again; one that is told nothing
|
|
374
|
+
// cannot know to.
|
|
375
|
+
throw new Error(
|
|
376
|
+
`could not read the tasks in goal ${goalId} at offset ${offset} `
|
|
377
|
+
+ `(HTTP ${r.status || '?'}) — refusing to report a partial goal as a complete one`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const page = rowsOf(r.data) || [];
|
|
381
|
+
rows.push(...page);
|
|
382
|
+
if (page.length < GOAL_PAGE) break;
|
|
383
|
+
if (rows.length >= GOAL_MAX_ROWS) {
|
|
384
|
+
throw new Error(`goal ${goalId} returned ${rows.length}+ rows — refusing to page further`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return rows;
|
|
388
|
+
}
|
|
389
|
+
|
|
289
390
|
async function fetchGoalTasks(api, goalId, limit) {
|
|
290
|
-
const
|
|
291
|
-
|
|
391
|
+
const rows = await fetchGoalTaskRows(api, goalId);
|
|
392
|
+
// The goal_id match stays even though the server filtered: a core pinned older
|
|
393
|
+
// than ADR 0086 §3 ignores an unknown query param instead of rejecting it, so
|
|
394
|
+
// without this line an un-upgraded instance would silently hand back the whole
|
|
395
|
+
// table and the planner would plan someone else's goal.
|
|
292
396
|
const mine = rows
|
|
293
397
|
.filter((t) => Number(t.goal_id) === Number(goalId) && !TERMINAL.has(t.status))
|
|
294
398
|
.slice(0, limit);
|
|
@@ -472,6 +576,10 @@ module.exports = {
|
|
|
472
576
|
readArgv,
|
|
473
577
|
edgeIds,
|
|
474
578
|
rowsOf,
|
|
579
|
+
fetchGoalTasks,
|
|
580
|
+
fetchGoalTaskRows,
|
|
581
|
+
declaredSurfaceExcludesMigrations,
|
|
582
|
+
GOAL_PAGE,
|
|
475
583
|
topoOrder,
|
|
476
584
|
chainFrom,
|
|
477
585
|
haltReasons,
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.640'; // 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');
|
|
@@ -50,7 +50,8 @@ test('linked members are @mentioned on the allowlist; unlinked members are named
|
|
|
50
50
|
assert.ok(posted.text.includes('<@111222333>'));
|
|
51
51
|
assert.ok(posted.text.includes('unlinked-two'), 'the unlinked member is still named');
|
|
52
52
|
assert.ok(posted.text.includes('within 90 minutes'), 'the mention IS the vote-within-X notice');
|
|
53
|
-
assert.ok(posted.text.includes('/
|
|
53
|
+
assert.ok(posted.text.includes('/board-room?item=11'), 'deep-links THAT item — the page scrolls to it');
|
|
54
|
+
assert.ok(!posted.text.includes('#board-room'), 'never emits the retired hash form (ADR 0266)');
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
test('a clockless sitting says so, and an amendment names its subject honestly', async () => {
|
|
@@ -221,7 +222,8 @@ test('the close is EDITED onto the same message, and the edit is stamped', async
|
|
|
221
222
|
assert.equal(edited.messageId, '555000111');
|
|
222
223
|
assert.ok(edited.text.includes('RATIFIED'), 'the summons now reads as the outcome');
|
|
223
224
|
assert.ok(!edited.text.includes('your vote is wanted'), 'nothing stale survives the edit');
|
|
224
|
-
assert.ok(edited.text.includes('/
|
|
225
|
+
assert.ok(edited.text.includes('/board-room?item=11'), 'the record is still one click away');
|
|
226
|
+
assert.ok(!edited.text.includes('#board-room'), 'never emits the retired hash form (ADR 0266)');
|
|
225
227
|
assert.equal(stamped, '11', 'edited_at answers "is this window still shown as open?" without asking Discord');
|
|
226
228
|
});
|
|
227
229
|
|
|
@@ -127,3 +127,63 @@ test('the government page no longer declares a board-room tab to redirect INTO',
|
|
|
127
127
|
const ids = [...GOV_SRC.matchAll(/\{ id: '([a-z-]+)', label: '[^']*', panel:/g)].map((m) => m[1]);
|
|
128
128
|
assert.ok(!ids.includes('board-room'), `board-room is a page, not a tab (found ${ids})`);
|
|
129
129
|
});
|
|
130
|
+
|
|
131
|
+
// ── the redirect is the ONLY thing that knows the old shape (task 1003736) ────
|
|
132
|
+
//
|
|
133
|
+
// The carve moved the room; task 1003736 moved the WRITERS. Its done-when clause
|
|
134
|
+
// is a statement about the whole tree — "nothing outside the government.js
|
|
135
|
+
// compatibility redirect emits the hash form" — and a claim of that shape rots
|
|
136
|
+
// the moment someone adds a fourth writer by copying an older one. Grepping once
|
|
137
|
+
// at review time proves it for that afternoon only, so it is pinned here instead.
|
|
138
|
+
//
|
|
139
|
+
// TWO TRAPS THIS ENCODES, both of which make the naive sweep wrong:
|
|
140
|
+
// 1. `#board-room` is ALSO the name of a Discord CHANNEL (channels.json, BV1
|
|
141
|
+
// R20). modules/government/CLAUDE.md and the BV1 spec name that channel, not
|
|
142
|
+
// a URL — repointing them would be a straightforward mistake, so the sweep
|
|
143
|
+
// hunts the URL form specifically and the quoted literal separately.
|
|
144
|
+
// 2. ADRs and session logs RECORD the old form on purpose; they are history and
|
|
145
|
+
// may not be rewritten to match today's code.
|
|
146
|
+
const SWEEP_DIRS = ['modules', 'src', 'scripts', 'docs/recipes', 'docs/packs', '.claude/skills'];
|
|
147
|
+
const SWEEP_EXTS = new Set(['.js', '.mjs', '.cjs', '.html', '.css', '.json', '.md']);
|
|
148
|
+
|
|
149
|
+
function sweepFiles() {
|
|
150
|
+
const out = [];
|
|
151
|
+
const walk = (rel) => {
|
|
152
|
+
const abs = path.join(ROOT, rel);
|
|
153
|
+
if (!fs.existsSync(abs)) return;
|
|
154
|
+
for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
155
|
+
if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;
|
|
156
|
+
const childRel = `${rel}/${e.name}`;
|
|
157
|
+
if (e.isDirectory()) walk(childRel);
|
|
158
|
+
else if (SWEEP_EXTS.has(path.extname(e.name))) out.push(childRel);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
for (const d of SWEEP_DIRS) walk(d);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
test('no writer anywhere in the tree still emits the retired hash URL', () => {
|
|
166
|
+
// The emitted form, not the bare fragment: `/government…#board-room`. This is
|
|
167
|
+
// what a link WRITER produces, and it is the thing task 1003736 repointed in
|
|
168
|
+
// builder-needs.js (hall banner + CLI nudge) and board-broadcast.js (Discord).
|
|
169
|
+
const offenders = sweepFiles().filter((rel) =>
|
|
170
|
+
/\/government[^\s'"`)\]]*#board-room/.test(fs.readFileSync(path.join(ROOT, rel), 'utf8')));
|
|
171
|
+
assert.deepEqual(offenders, [],
|
|
172
|
+
`these still emit the retired deep link — point them at /board-room?item=<id>:\n ${offenders.join('\n ')}`);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('the hash literal survives only in the redirect that consumes it', () => {
|
|
176
|
+
// The QUOTED literal is the runtime value — the thing a branch compares
|
|
177
|
+
// against. Comments discussing the retired tab are documentation and are left
|
|
178
|
+
// alone (they use backticks, not quotes), which is why this looks for quotes.
|
|
179
|
+
const allowed = 'modules/hall-ui/public/government.js';
|
|
180
|
+
const offenders = sweepFiles().filter((rel) => rel !== allowed
|
|
181
|
+
&& /['"]#board-room['"]/.test(fs.readFileSync(path.join(ROOT, rel), 'utf8')));
|
|
182
|
+
assert.deepEqual(offenders, [],
|
|
183
|
+
`only ${allowed}'s redirect may still know the old shape, found in:\n ${offenders.join('\n ')}`);
|
|
184
|
+
|
|
185
|
+
// And the allowlisted file must actually still contain it — an allowlist that
|
|
186
|
+
// guards a string nobody has any more is a rule quietly protecting nothing.
|
|
187
|
+
assert.match(GOV_SRC, /['"]#board-room['"]/,
|
|
188
|
+
'the compatibility redirect itself is gone — old Discord links now dead-end');
|
|
189
|
+
});
|
|
@@ -199,14 +199,18 @@ test('pending votes are action_needed and deep-link the oldest sitting', () => {
|
|
|
199
199
|
const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 9, first_item_id: '1' } });
|
|
200
200
|
assert.equal(n.id, 'board_votes');
|
|
201
201
|
assert.equal(n.state, 'action_needed');
|
|
202
|
-
assert.equal(n.action.href, '/
|
|
203
|
-
// The
|
|
204
|
-
|
|
202
|
+
assert.equal(n.action.href, '/board-room?item=1');
|
|
203
|
+
// The item is a query param, because `/board-room` reads `?item=` and a
|
|
204
|
+
// FRAGMENT would never reach the server (ADR 0266). The old
|
|
205
|
+
// `/government…#board-room` form survives only as government.js's
|
|
206
|
+
// compatibility redirect — no writer may emit it.
|
|
207
|
+
assert.ok(!n.action.href.includes('#board-room'), 'never emits the retired hash form');
|
|
205
208
|
});
|
|
206
209
|
|
|
207
210
|
test('a count with no item id still links the room', () => {
|
|
208
211
|
const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 2 } });
|
|
209
|
-
assert.equal(n.action.href, '/
|
|
212
|
+
assert.equal(n.action.href, '/board-room');
|
|
213
|
+
assert.ok(!n.action.href.includes('#board-room'), 'never emits the retired hash form');
|
|
210
214
|
});
|
|
211
215
|
|
|
212
216
|
test('the need reaches computeNeeds as an action_needed item', () => {
|
package/tests/sequence.mjs
CHANGED
|
@@ -16,10 +16,15 @@ const {
|
|
|
16
16
|
pickNext,
|
|
17
17
|
summarize,
|
|
18
18
|
MIN_SPEC_CHARS,
|
|
19
|
+
fetchGoalTasks,
|
|
20
|
+
fetchGoalTaskRows,
|
|
21
|
+
declaredSurfaceExcludesMigrations,
|
|
22
|
+
GOAL_PAGE,
|
|
19
23
|
} = require('../scripts/gds/sequence.js');
|
|
20
24
|
|
|
21
25
|
let passed = 0, failed = 0;
|
|
22
26
|
const t = (n, f) => { try { f(); passed++; console.log(` PASS ${n}`); } catch (e) { failed++; console.log(` FAIL ${n}\n ${e.message}`); } };
|
|
27
|
+
const ta = async (n, f) => { try { await f(); passed++; console.log(` PASS ${n}`); } catch (e) { failed++; console.log(` FAIL ${n}\n ${e.message}`); } };
|
|
23
28
|
|
|
24
29
|
// A description long enough to clear the thin-spec gate, so fixtures testing
|
|
25
30
|
// OTHER halt reasons don't trip it incidentally.
|
|
@@ -327,5 +332,157 @@ t('pickNext on an empty plan returns no pick and nothing skipped', () => {
|
|
|
327
332
|
assert.equal(r.head, null);
|
|
328
333
|
});
|
|
329
334
|
|
|
335
|
+
// ------------------------------------------------- goal paging (task 1003699)
|
|
336
|
+
|
|
337
|
+
// A fake board big enough to have the cliff the bug fell off. The UNFILTERED
|
|
338
|
+
// listing is capped at UNFILTERED_CAP rows exactly as the real route caps
|
|
339
|
+
// ?limit at 1000, so a row past it is genuinely unreachable that way — which is
|
|
340
|
+
// what makes the acceptance test meaningful rather than a small-instance smoke.
|
|
341
|
+
const UNFILTERED_CAP = 1000;
|
|
342
|
+
|
|
343
|
+
function fakeBoard({ goalId = 42, rowsInGoal = 3, targetIndex = 1100, total = 1200, failPages = [] } = {}) {
|
|
344
|
+
const table = [];
|
|
345
|
+
for (let i = 0; i < total; i += 1) {
|
|
346
|
+
table.push({ id: 10000 + i, title: `filler ${i}`, status: 'ready', goal_id: 999, touches: [] });
|
|
347
|
+
}
|
|
348
|
+
// The task under test sits PAST the unfiltered cap, and is in the goal.
|
|
349
|
+
table[targetIndex] = { id: 777, title: 'the task past the window', status: 'ready', goal_id: goalId, touches: [] };
|
|
350
|
+
for (let k = 1; k < rowsInGoal; k += 1) {
|
|
351
|
+
table[targetIndex - k] = { id: 800 + k, title: `sibling ${k}`, status: 'ready', goal_id: goalId, touches: [] };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const calls = [];
|
|
355
|
+
const api = {
|
|
356
|
+
tasks: {
|
|
357
|
+
async getTasks({ query } = {}) {
|
|
358
|
+
const q = query || {};
|
|
359
|
+
calls.push({ ...q });
|
|
360
|
+
if (q.offset === 0) return { ok: false, status: 400 }; // the real route's rule
|
|
361
|
+
const page = calls.length - 1;
|
|
362
|
+
if (failPages.includes(page)) return { ok: false, status: 500 };
|
|
363
|
+
const rows = q.goal_id === undefined
|
|
364
|
+
? table.slice(0, UNFILTERED_CAP)
|
|
365
|
+
: table.filter((r) => Number(r.goal_id) === Number(q.goal_id));
|
|
366
|
+
const off = Number(q.offset) || 0;
|
|
367
|
+
const lim = Number(q.limit) || rows.length;
|
|
368
|
+
return { ok: true, data: { tasks: rows.slice(off, off + lim) } };
|
|
369
|
+
},
|
|
370
|
+
async getTasksId({ id }) {
|
|
371
|
+
const row = table.find((r) => Number(r.id) === Number(id));
|
|
372
|
+
return row ? { ok: true, data: { task: { ...row, dependencies: [], dependents: [] } } } : { ok: false };
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
return { api, calls, table };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
await ta('the unfiltered listing genuinely cannot see the task — the premise of the bug', async () => {
|
|
380
|
+
const { api } = fakeBoard();
|
|
381
|
+
const r = await api.tasks.getTasks({ query: { limit: 1000 } });
|
|
382
|
+
const ids = (r.data.tasks || []).map((x) => Number(x.id));
|
|
383
|
+
assert.equal(ids.length, UNFILTERED_CAP);
|
|
384
|
+
assert.ok(!ids.includes(777), 'task 777 must sit outside the unfiltered window, or this suite proves nothing');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await ta('--goal finds a task that is NOT in the first page of the unfiltered listing', async () => {
|
|
388
|
+
const { api } = fakeBoard();
|
|
389
|
+
const byId = await fetchGoalTasks(api, 42, 50);
|
|
390
|
+
assert.ok(byId.has(777), 'the goal-scoped read must return the task past the 1000-row window');
|
|
391
|
+
assert.equal(byId.size, 3, 'and its two siblings, and nothing from another goal');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await ta('a goal larger than one page is paged until exhausted, and offset=0 is never sent', async () => {
|
|
395
|
+
const { api, calls } = fakeBoard({ rowsInGoal: GOAL_PAGE + 7, targetIndex: 1100, total: 2000 });
|
|
396
|
+
const rows = await fetchGoalTaskRows(api, 42);
|
|
397
|
+
assert.equal(rows.length, GOAL_PAGE + 7, 'every row in the goal, across pages');
|
|
398
|
+
assert.ok(calls.length >= 2, 'more than one page was requested');
|
|
399
|
+
assert.equal(calls[0].offset, undefined, 'page one sends no offset (the route rejects offset=0)');
|
|
400
|
+
assert.equal(calls[1].offset, GOAL_PAGE, 'page two continues from the page size');
|
|
401
|
+
assert.ok(calls.every((c) => c.goal_id === 42), 'every page is scoped server-side');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
await ta('an unreadable FIRST page throws — it must never read as an empty chain', async () => {
|
|
405
|
+
const { api } = fakeBoard({ failPages: [0] });
|
|
406
|
+
await assert.rejects(() => fetchGoalTaskRows(api, 42), (e) => {
|
|
407
|
+
assert.match(e.message, /refusing to report a partial goal/);
|
|
408
|
+
assert.match(e.message, /goal 42 at offset 0/, 'the message names which page failed');
|
|
409
|
+
return true;
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
await ta('a LATER page failing also throws — a partial goal must not read as a whole one', async () => {
|
|
414
|
+
const { api } = fakeBoard({ rowsInGoal: GOAL_PAGE + 7, targetIndex: 1100, total: 2000, failPages: [1] });
|
|
415
|
+
await assert.rejects(() => fetchGoalTaskRows(api, 42), /refusing to report a partial goal/);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await ta('the goal_id match is kept as a guard against a core that ignores the param', async () => {
|
|
419
|
+
const { api } = fakeBoard();
|
|
420
|
+
// Simulate a pinned older core: the goal_id filter is silently ignored.
|
|
421
|
+
const inner = api.tasks.getTasks;
|
|
422
|
+
api.tasks.getTasks = ({ query } = {}) => inner({ query: { ...query, goal_id: undefined } });
|
|
423
|
+
const byId = await fetchGoalTasks(api, 42, 50);
|
|
424
|
+
assert.equal(byId.size, 0, 'a whole-table answer must not become someone else\'s plan');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// -------------------------------------------- needs_migration (task 1003699)
|
|
428
|
+
|
|
429
|
+
await ta('needs_migration no longer halts on the word alone (task 1003697\'s shape)', async () => {
|
|
430
|
+
// The real 1003697: the flag set from prose, four touched files, none of them a
|
|
431
|
+
// migration — and a description that DOES contain a literal migrations/ path,
|
|
432
|
+
// which is why keying on the prose would not have fixed anything.
|
|
433
|
+
const t1003697 = task({
|
|
434
|
+
id: 1003697,
|
|
435
|
+
needs_migration: true,
|
|
436
|
+
touches: ['config/rename-history-baseline.json', 'scripts/gds/gds-literal-scan.js',
|
|
437
|
+
'scripts/gds/rename-history-check.js', 'tests/rename_history_restraint.mjs'],
|
|
438
|
+
description: `${SPEC} see migrations/163_goal_dep_trigger.sql — DO NOT change any migration`,
|
|
439
|
+
});
|
|
440
|
+
assert.ok(declaredSurfaceExcludesMigrations(t1003697));
|
|
441
|
+
const reasons = haltReasons(t1003697, { rank: 'metic', claimableIds: new Set([1003697]) });
|
|
442
|
+
assert.ok(!reasons.some((r) => r.code === 'needs_migration'), reasons.map((r) => r.code).join(','));
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
await ta('a task that genuinely touches migrations/ still halts', async () => {
|
|
446
|
+
const real = task({ id: 5, needs_migration: true, touches: ['migrations/214_add_column.sql', 'src/x.js'] });
|
|
447
|
+
assert.equal(declaredSurfaceExcludesMigrations(real), false);
|
|
448
|
+
const reasons = haltReasons(real, { rank: 'metic', claimableIds: new Set([5]) });
|
|
449
|
+
assert.ok(reasons.some((r) => r.code === 'needs_migration'));
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
await ta('a flagged task with NO declared surface keeps halting — silence is not evidence', async () => {
|
|
453
|
+
for (const touches of [[], undefined]) {
|
|
454
|
+
const unknown = task({ id: 6, needs_migration: true, touches });
|
|
455
|
+
assert.equal(declaredSurfaceExcludesMigrations(unknown), false);
|
|
456
|
+
const reasons = haltReasons(unknown, { rank: 'metic', claimableIds: new Set([6]) });
|
|
457
|
+
assert.ok(reasons.some((r) => r.code === 'needs_migration'), `touches=${JSON.stringify(touches)}`);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
await ta('a MODULE-OWNED migration counts — modules/<key>/migrations/ is real schema work here', async () => {
|
|
462
|
+
// ADR 0083 / docs/modules-contract.md: ten modules ship their own migrations.
|
|
463
|
+
// An anchored ^migrations/ test called these "not a migration" and suppressed
|
|
464
|
+
// the halt — and nothing else would have caught it, because the protected-path
|
|
465
|
+
// registry's own migrations/ glob is root-anchored too.
|
|
466
|
+
for (const p of [
|
|
467
|
+
'modules/economy/migrations/012_add_col.sql',
|
|
468
|
+
'modules/lifecycle/migrations/163_goal_dep_trigger.sql',
|
|
469
|
+
'src/db/migrations/x.sql',
|
|
470
|
+
]) {
|
|
471
|
+
const real = task({ id: 7, needs_migration: true, touches: [p, 'src/x.js'] });
|
|
472
|
+
assert.equal(declaredSurfaceExcludesMigrations(real), false, p);
|
|
473
|
+
const reasons = haltReasons(real, { rank: 'metic', claimableIds: new Set([7]) });
|
|
474
|
+
assert.ok(reasons.some((r) => r.code === 'needs_migration'), `${p} must still halt`);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await ta('the migrations match reads a path SEGMENT, so a lookalike filename does not count', async () => {
|
|
479
|
+
assert.equal(declaredSurfaceExcludesMigrations(task({ needs_migration: true, touches: ['./migrations/9.sql'] })), false);
|
|
480
|
+
assert.equal(declaredSurfaceExcludesMigrations(task({ needs_migration: true, touches: ['Migrations/9.sql'] })), false);
|
|
481
|
+
assert.equal(declaredSurfaceExcludesMigrations(task({ needs_migration: true, touches: ['modules\\economy\\migrations\\012.sql'] })), false);
|
|
482
|
+
// The word in a FILE name, with no migrations directory, is not schema work.
|
|
483
|
+
assert.equal(declaredSurfaceExcludesMigrations(task({ needs_migration: true, touches: ['docs/migrations-guide.md'] })), true);
|
|
484
|
+
assert.equal(declaredSurfaceExcludesMigrations(task({ needs_migration: true, touches: ['docs/recipes/migrations.md'] })), true);
|
|
485
|
+
});
|
|
486
|
+
|
|
330
487
|
console.log(`\nsequence: ${passed} passed, ${failed} failed`);
|
|
331
488
|
process.exit(failed ? 1 : 0);
|