@bongos/core 1.19.652 → 1.19.654

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 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.652",
6
- "core_contract": "1.19.652",
7
- "source_commit": "addabb3fbae5b20acb74b0d7ee3cf369d6a4e8f5",
5
+ "core_version": "1.19.654",
6
+ "core_contract": "1.19.654",
7
+ "source_commit": "29859bbb518d99362867ba988703fdbf1d4f9472",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-10T21:00:32.591Z",
9
+ "built_at": "2026-09-10T23:57:14.296Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 472,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2606,
20
- "tree_sha256": "0625f6b55212a6ec7203db50d856c576c15b3eda23ea574a46fb212641dc163c",
20
+ "tree_sha256": "c678060ed77c22de9946d24d4668466c564defa104b075a8076c1a5964fe937a",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/backlog-review/SKILL.md",
@@ -2782,7 +2782,7 @@
2782
2782
  {
2783
2783
  "path": "docs/module-api-changelog.md",
2784
2784
  "mode": "0000644",
2785
- "sha256": "7b9836503b654f29b308db8418484f1f707bfe6edcd32aaae6aa1c1a17216b5b"
2785
+ "sha256": "d45c6981ad6bd64f4b0fc09e9d28fe87231b8d65da676620246f8eccc803f091"
2786
2786
  },
2787
2787
  {
2788
2788
  "path": "docs/modules-contract.md",
@@ -5567,7 +5567,7 @@
5567
5567
  {
5568
5568
  "path": "modules/ideas/routes/inbox.js",
5569
5569
  "mode": "0000644",
5570
- "sha256": "b3df5f0d4273f44af60a7e09a8bef026a0f02085c7823c7912c70d067e55e127"
5570
+ "sha256": "0f5997e58ca3f8ee33e84948136697519f095670387f0ae26cadb325f2389268"
5571
5571
  },
5572
5572
  {
5573
5573
  "path": "modules/ideas/routing.js",
@@ -7742,12 +7742,12 @@
7742
7742
  {
7743
7743
  "path": "package-lock.json",
7744
7744
  "mode": "0000644",
7745
- "sha256": "9fcb8bfb10f688b6442649f49bd4cc91454e56a44169414ca8839caab3fc05c7"
7745
+ "sha256": "9370d237943d31161eb264dcd700e1fe6caea5f32a234e05cf17f94cdba7db5e"
7746
7746
  },
7747
7747
  {
7748
7748
  "path": "package.json",
7749
7749
  "mode": "0000644",
7750
- "sha256": "ed0d91d1a9a16e1f8e97ce2b538de568c297bc281cf15ea99f22f9bb92f1b5f0"
7750
+ "sha256": "bc4361fd3c305fcb47bc8769dbad015475a2f7ef4e1e6795c7027953e5b259ec"
7751
7751
  },
7752
7752
  {
7753
7753
  "path": "public-docs/index.html",
@@ -8187,7 +8187,7 @@
8187
8187
  {
8188
8188
  "path": "scripts/gds/fitness-checks-vocabulary.js",
8189
8189
  "mode": "0000644",
8190
- "sha256": "5ea954ec0bf26e5192a369d0f94dcb3b8bf2e263c9e927c66a01791962a46995"
8190
+ "sha256": "f64905f88718e5617ee97584dada757367497eed350bfb41814d257c9d2ec680"
8191
8191
  },
8192
8192
  {
8193
8193
  "path": "scripts/gds/fitness-checks-write-validation.js",
@@ -8207,7 +8207,7 @@
8207
8207
  {
8208
8208
  "path": "scripts/gds/fitness.js",
8209
8209
  "mode": "0000644",
8210
- "sha256": "3593c992b397333ee85f11dc2ea6325c3c96a2d1e5b813204da32c34c493b236"
8210
+ "sha256": "b30c5c2b3bf6465a63a7124327ad40f94987088d1d87fcc110fec9281539a0a1"
8211
8211
  },
8212
8212
  {
8213
8213
  "path": "scripts/gds/gate-review.js",
@@ -8412,7 +8412,7 @@
8412
8412
  {
8413
8413
  "path": "scripts/gds/main-audit.js",
8414
8414
  "mode": "0000644",
8415
- "sha256": "35047f05c6059aee0cb64b13ef1495e4db1f3885b5b9c8cac5c60283069a059d"
8415
+ "sha256": "4b27716f74bd13b0500c14b2607ea9f9e2584c4a27280bd075418772ac4dc644"
8416
8416
  },
8417
8417
  {
8418
8418
  "path": "scripts/gds/me.js",
@@ -9012,7 +9012,7 @@
9012
9012
  {
9013
9013
  "path": "scripts/gds/spark.js",
9014
9014
  "mode": "0000755",
9015
- "sha256": "dfd5aa72caa26eb2c8cf3063e1b4f51e1f4bcd7fddcdd93d243279c130850628"
9015
+ "sha256": "3cc086defcaf14df25db27646596f2332ac79dff97c95e72d4cff329d8db9189"
9016
9016
  },
9017
9017
  {
9018
9018
  "path": "scripts/gds/spike-task-map.js",
@@ -9502,7 +9502,7 @@
9502
9502
  {
9503
9503
  "path": "src/module-api.js",
9504
9504
  "mode": "0000644",
9505
- "sha256": "d0459d72e42fdd8e712146f10545b27b6f82a310afa7bd0acb1b9290e1ef17b3"
9505
+ "sha256": "175d5a93b971e2da73a24186a0e9ed7288da65aed61aa9f86d5a0c85f30bf53f"
9506
9506
  },
9507
9507
  {
9508
9508
  "path": "src/module-loader/catalog.js",
@@ -10107,7 +10107,7 @@
10107
10107
  {
10108
10108
  "path": "tests/cli_exit_no_abort.mjs",
10109
10109
  "mode": "0000644",
10110
- "sha256": "7bc4d54d29458c70cd545aecac1ca1f7b8cc13d7b1dacaa90a8c2184ef553304"
10110
+ "sha256": "7b489890ab6e2baa6aefe784b81852eaaac39cdc50af5974de7eee815baee510"
10111
10111
  },
10112
10112
  {
10113
10113
  "path": "tests/cli_lib_formatters.mjs",
@@ -10957,7 +10957,7 @@
10957
10957
  {
10958
10958
  "path": "tests/government_vocabulary.mjs",
10959
10959
  "mode": "0000644",
10960
- "sha256": "71bd23242e53b1acd15fddedcb093790a847471c8c55485614d379d29e3065f3"
10960
+ "sha256": "444f0638dccebc42e5a371820ed565c665d4373c198e1581389ab222bf267c5e"
10961
10961
  },
10962
10962
  {
10963
10963
  "path": "tests/grade_aggregates_unavailable.mjs",
@@ -11227,7 +11227,7 @@
11227
11227
  {
11228
11228
  "path": "tests/idea_develop_spark.mjs",
11229
11229
  "mode": "0000644",
11230
- "sha256": "a498e2dfc87dbb5050d414ff04ebf4178900edb771be384fe91247e452a37a02"
11230
+ "sha256": "fe55e01a0be2aa6ca0bc536469eefcee7d44f40dfa0767fedf1709aed3f7d81e"
11231
11231
  },
11232
11232
  {
11233
11233
  "path": "tests/idea_full_capture.mjs",
@@ -11267,7 +11267,7 @@
11267
11267
  {
11268
11268
  "path": "tests/idea_resubmit.mjs",
11269
11269
  "mode": "0000644",
11270
- "sha256": "51e1286235cbde1b56188f29b0e2bd12ac73a9637dbf1c16b325f10147256f27"
11270
+ "sha256": "56ef1d4405ec63a9c095000411774f533b3e34cdb89184bd4351e58313dc2975"
11271
11271
  },
11272
11272
  {
11273
11273
  "path": "tests/idea_route_attribution.mjs",
@@ -1753,5 +1753,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1753
1753
  landed since 1.19.650 with no explicit bump. run 34526936330. (task 1002620)
1754
1754
  1.19.652 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1755
1755
  landed since 1.19.651 with no explicit bump. run 34529655648. (task 1002620)
1756
+ 1.19.653 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1757
+ landed since 1.19.652 with no explicit bump. run 34543713846. (task 1002620)
1758
+ 1.19.654 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1759
+ landed since 1.19.653 with no explicit bump. run 34544345437. (task 1002620)
1756
1760
  ---------------------------------------------------------------------------
1757
1761
  ```
@@ -63,6 +63,25 @@ function registerSeams() {
63
63
  }
64
64
  }
65
65
 
66
+ // The author-facing shape of a ratification window this request just opened —
67
+ // spelled once because all three open sites (capture, develop, resubmit) return
68
+ // the same thing to the same readers (task 1003654).
69
+ //
70
+ // `closes_at` is legitimately NULL: it is the no-clock constitution, not a
71
+ // missing value (modules/government/db.js — a NULL deadline never expires).
72
+ // That is exactly why `pass_rule` travels WITH it. A caller holding only the
73
+ // timestamp cannot tell a clockless sitting from a broken one, so it prints the
74
+ // raw null — which is what the R157 proof walk caught spark.js doing ("open
75
+ // until null"). With the rule in hand a renderer can say HOW the sitting closes
76
+ // instead, the way the Board Room's own clockLine() already does.
77
+ function boardWindowPayload(item) {
78
+ return {
79
+ item_id: item.id,
80
+ closes_at: item.closes_at ?? null,
81
+ pass_rule: (item.constitution && item.constitution.pass_rule) || null,
82
+ };
83
+ }
84
+
66
85
  module.exports = function buildInboxRouter() {
67
86
  registerSeams();
68
87
  const router = express.Router();
@@ -227,7 +246,7 @@ module.exports = function buildInboxRouter() {
227
246
  completenessScore: row.completeness_score,
228
247
  });
229
248
  if (w && w.opened && w.item) {
230
- idea.board_window = { item_id: w.item.id, closes_at: w.item.closes_at };
249
+ idea.board_window = boardWindowPayload(w.item);
231
250
  } else if (w && w.reason === 'below_bar') {
232
251
  // R09 (goal 1000069, criterion C2): a sub-bar idea returns to
233
252
  // its author WITH A REASON THEY CAN ACT ON — the weakest section
@@ -673,7 +692,7 @@ module.exports = function buildInboxRouter() {
673
692
  try {
674
693
  if (government && government.openBoardWindowForIdea) {
675
694
  const w = await government.openBoardWindowForIdea({ ideaId: id, completenessScore: row.completeness_score });
676
- if (w && w.opened && w.item) board_window = { item_id: w.item.id, closes_at: w.item.closes_at };
695
+ if (w && w.opened && w.item) board_window = boardWindowPayload(w.item);
677
696
  else if (w && w.reason === 'below_bar') {
678
697
  const flag = weakestSectionPayload(req.body.field_values, templateKey ? { templateKey } : undefined);
679
698
  if (flag) out.rejection = flag; // still short — the NEW weakest section, fresh feedback
@@ -957,7 +976,7 @@ module.exports = function buildInboxRouter() {
957
976
  const government = api.resolveOptional && api.resolveOptional('government');
958
977
  if (government && government.openBoardWindowForIdea) {
959
978
  const w = await government.openBoardWindowForIdea({ ideaId: id, completenessScore: row.completeness_score });
960
- if (w && w.opened && w.item) board_window = { item_id: w.item.id, closes_at: w.item.closes_at };
979
+ if (w && w.opened && w.item) board_window = boardWindowPayload(w.item);
961
980
  else if (w && w.reason === 'below_bar') {
962
981
  // The same honest feedback the resubmit path gives: which section is
963
982
  // thinnest, computed from what was just written. Derived, never stored.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.652",
3
+ "version": "1.19.654",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.652",
9
+ "version": "1.19.654",
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.652",
3
+ "version": "1.19.654",
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",
@@ -48,11 +48,36 @@ const VOCAB_ALLOW = [
48
48
  },
49
49
  ];
50
50
 
51
- // A line CITING a historical artifact by its real name: an ADR filename, the
52
- // docs/governance/ evidence folder, or one of the already-applied
53
- // governance_NNN migrations. These names are permanent (see the header), so a
54
- // reference to one is a citation, not drift.
55
- const VOCAB_HISTORICAL_REF = /docs\/adr\/|docs\/governance\/|docs\/session-logs\/|governance_0\d\d/;
51
+ // A line CITING a durable artifact by its real name: an ADR filename, the
52
+ // docs/governance/ evidence folder, one of the already-applied migrations, or
53
+ // the cross-goal pass-event contract. These names are permanent (see the
54
+ // header), so a reference to one is a citation, not drift.
55
+ //
56
+ // The migration family is `govern(ance|ment)_NNN` on purpose. government_001
57
+ // RENAMED the governance_NNN family to government_NNN, so half the applied
58
+ // migrations on disk carry each spelling and BOTH are permanent — and the
59
+ // rename migration's own filename (`government_001_rename_from_governance.sql`)
60
+ // says the banned word while matching neither half of the old regex. That is
61
+ // not drift; it is the one filename that has to name what it renamed.
62
+ //
63
+ // docs/specs/board-room-pass-event-contract.md is here for the same reason a
64
+ // migration filename is: it is the agreed cross-goal name of the economy-side
65
+ // award (awardIdeaGovernancePassCredit) and the credit_log reason family
66
+ // idea.credit.governance_pass:<id>. Those are durable ledger rows and another
67
+ // goal's method signature — a line pointing at the contract is citing a name
68
+ // this surface does not own and cannot rename.
69
+ const VOCAB_HISTORICAL_REF = /docs\/adr\/|docs\/governance\/|docs\/session-logs\/|govern(?:ance|ment)_0\d\d|board-room-pass-event-contract/;
70
+
71
+ // Naming the RULE stays legal — a line that cites ADR 0174 or the task that
72
+ // built the wall is meta-commentary about the ban, and meta-commentary has to
73
+ // be able to say the banned word. Same principle as check 19b's
74
+ // GOAL_VOCAB_CITATION, and deliberately preferred over a per-file VOCAB_ALLOW
75
+ // entry: the exemption belongs to the CITATION, not to whichever file happens
76
+ // to hold it today. (Adding '.md' to VOCAB_EXTS brought the module's own
77
+ // nested CLAUDE.md into scope — the file that TEACHES this rule, which states
78
+ // it in the same breath. Three allowlist entries would have taken the cap of 5
79
+ // to its limit for lines whose only sin is quoting the rule correctly.)
80
+ const VOCAB_CITATION = /ADR 0174|task 1003008/;
56
81
 
57
82
  const VOCAB_SURFACE = [
58
83
  'modules/government',
@@ -67,7 +92,13 @@ const VOCAB_SURFACE = [
67
92
  'modules/hall-ui/public/board-room.html',
68
93
  'modules/hall-ui/public/board-room.css',
69
94
  ];
70
- const VOCAB_EXTS = new Set(['.js', '.json', '.html', '.css']);
95
+ // '.md' is load-bearing, not tidiness (task 1003120). A nested CLAUDE.md is
96
+ // INSTRUCTION: it loads on demand for every agent working in the module, and
97
+ // the agent that reads the wrong vocabulary there goes on to write it into the
98
+ // code. Leaving markdown unscanned is how modules/government/CLAUDE.md came to
99
+ // assert the INVERSE of ADR 0174 and survive a criterion review (task 1003118).
100
+ // The doc surface is the higher-leverage one to guard, and it was the unguarded one.
101
+ const VOCAB_EXTS = new Set(['.js', '.json', '.html', '.css', '.md']);
71
102
  const VOCAB_BANNED = [
72
103
  { re: /\brole/i, label: 'role', hint: '"role" is reserved for the crafts (Artist / Ideator / Builder). On this surface the noun is RANK.' },
73
104
  { re: /governance/i, label: 'governance', hint: 'the pre-ADR-0174 name of this surface. It is the GOVERNMENT now.' },
@@ -202,6 +233,9 @@ function checkVocabularyWall() {
202
233
  // too, or the code could not point at the migration that created its own
203
234
  // tables. Same principle, applied to the line rather than the file.
204
235
  if (VOCAB_HISTORICAL_REF.test(scanLine)) continue;
236
+ // A line that CITES the rule is meta-commentary about the ban, not an
237
+ // instance of it. Costs no allowlist entry and travels with the sentence.
238
+ if (VOCAB_CITATION.test(scanLine)) continue;
205
239
  const excused = VOCAB_ALLOW.some((a) => a.file === rel && scanLine.toLowerCase().includes(a.match));
206
240
  if (excused) continue;
207
241
  violations.push(`${rel}:${i + 1}: "${b.label}" — ${b.hint} → ${line.trim().slice(0, 90)}`);
@@ -214,8 +248,52 @@ function checkVocabularyWall() {
214
248
  hardFail: violations.length > 0,
215
249
  violations,
216
250
  warnings: [],
217
- note: `${scanned} government-surface file(s) scanned for "role"/"governance"; `
218
- + `${VOCAB_ALLOW.length} documented allowlist entr(y|ies). Historical migrations + docs/adr are excluded by construction, not suppressed.`,
251
+ note: `${scanned} government-surface file(s) scanned for "role"/"governance" (${[...VOCAB_EXTS].sort().join(' ')}); `
252
+ + `${VOCAB_ALLOW.length} documented allowlist entr(y|ies), plus the citation rule. `
253
+ + 'Historical migrations + docs/adr are excluded by construction, not suppressed.',
254
+ };
255
+ }
256
+
257
+ // ── Check 19c — a rename sweep that ate its own arrow (task 1003120) ─────────
258
+ //
259
+ // THE HOLE CHECK 19 CANNOT SEE. Checks 19 and 19b grep for a BANNED WORD, so
260
+ // they are structurally blind to the damage that actually happened in task
261
+ // 1003118: a find-and-replace ran over a doc that was DESCRIBING the rename, and
262
+ // swept BOTH sides of every rename pair. `governance` → `government` became
263
+ // `government` → `government`. Not one banned word survives that edit, so the
264
+ // wall passed — while the nested CLAUDE.md now taught the next agent that the
265
+ // old name and the new name were the same word, and asserted the inverse of
266
+ // ADR 0174 through a criterion review.
267
+ //
268
+ // An arrow between two identical names states nothing. It is only ever the
269
+ // fossil of a sweep, so flagging the SHAPE catches the defect on the day it
270
+ // lands, with no vocabulary to enumerate. Measured on the whole tree: the one
271
+ // hit outside this surface is ADR 0194's `open` → `open`, a real mapping
272
+ // sentence — which is why the check is scoped to the government surface (the
273
+ // files the rename actually swept) rather than run repo-wide.
274
+ const RENAME_IDENTITY_RE = /`([\w./-]+)` *(?:→|->) *`\1`/;
275
+
276
+ function checkGovernmentRenameIdentity() {
277
+ const violations = [];
278
+ let scanned = 0;
279
+ for (const { abs, rel } of vocabFilesToScan()) {
280
+ scanned += 1;
281
+ fs.readFileSync(abs, 'utf8').split('\n').forEach((line, i) => {
282
+ const m = RENAME_IDENTITY_RE.exec(line);
283
+ if (!m) return;
284
+ violations.push(`${rel}:${i + 1}: \`${m[1]}\` → \`${m[1]}\` — a rename arrow between two IDENTICAL names states nothing. `
285
+ + 'A sweep ate one side of the pair (task 1003118); restore the name it renamed FROM.'
286
+ + ` → ${line.trim().slice(0, 90)}`);
287
+ });
288
+ }
289
+ return {
290
+ name: 'a rename arrow names two different things (task 1003120)',
291
+ ok: violations.length === 0,
292
+ hardFail: violations.length > 0,
293
+ violations,
294
+ warnings: [],
295
+ note: `${scanned} government-surface file(s) scanned for \`X\` → \`X\` pairs; no allowlist — `
296
+ + 'an arrow between identical names has no legitimate form to excuse.',
219
297
  };
220
298
  }
221
299
 
@@ -225,12 +303,15 @@ module.exports = {
225
303
  GOAL_VOCAB_LINE_SCOPED,
226
304
  GOAL_VOCAB_SURFACE,
227
305
  LIFECYCLE_DB_FILES,
306
+ RENAME_IDENTITY_RE,
228
307
  VOCAB_ALLOW,
229
308
  VOCAB_BANNED,
309
+ VOCAB_CITATION,
230
310
  VOCAB_EXTS,
231
311
  VOCAB_HISTORICAL_REF,
232
312
  VOCAB_SURFACE,
233
313
  checkGoalMembershipVocabulary,
314
+ checkGovernmentRenameIdentity,
234
315
  checkVocabularyWall,
235
316
  vocabFilesToScan,
236
317
  };
@@ -36,7 +36,7 @@ const { resolveCoreRoot, resolveInstanceRoot } = require('../../src/instance-con
36
36
  const { CORE_ROOT, INSTANCE_ROOT, MODULE_ROOTS, REPO_ROOT, rel, walkJs, walkJsAllRoots } = require('./fitness-lib.js');
37
37
  const { IDENTITY_SCAN_ALLOWED, IDENTITY_SCAN_EXCLUDE, IDENTITY_SCAN_EXTRA_FILES, IDENTITY_SCAN_EXTS, IDENTITY_SCAN_ROOTS, IDENTITY_SCAN_SKIP_DIRS, PROMPT_IDENTITY_LITERALS, checkDesignContract, checkKernelPromptsNoHardcodedIdentity, checkNeutralConfigsNoHostIdentity, collectIdentityFiles, scanPromptIdentity, stripCommentsForIdentity } = require('./fitness-checks-identity.js');
38
38
  const { WRITE_VALIDATION_BASELINE, WRITE_VALIDATION_EXEMPT, checkWriteRoutesValidated, scanUnvalidatedWriteRoutes } = require('./fitness-checks-write-validation.js');
39
- const { GOAL_VOCAB_CITATION, GOAL_VOCAB_LINE_SCOPED, GOAL_VOCAB_SURFACE, LIFECYCLE_DB_FILES, VOCAB_ALLOW, VOCAB_BANNED, VOCAB_EXTS, VOCAB_HISTORICAL_REF, VOCAB_SURFACE, checkGoalMembershipVocabulary, checkVocabularyWall, vocabFilesToScan } = require('./fitness-checks-vocabulary.js');
39
+ const { GOAL_VOCAB_CITATION, GOAL_VOCAB_LINE_SCOPED, GOAL_VOCAB_SURFACE, LIFECYCLE_DB_FILES, RENAME_IDENTITY_RE, VOCAB_ALLOW, VOCAB_BANNED, VOCAB_CITATION, VOCAB_EXTS, VOCAB_HISTORICAL_REF, VOCAB_SURFACE, checkGoalMembershipVocabulary, checkGovernmentRenameIdentity, checkVocabularyWall, vocabFilesToScan } = require('./fitness-checks-vocabulary.js');
40
40
  // ============================================================================
41
41
  // Check 1 — core ↔ host/game import boundary (ADR 0062 §1 cut-line)
42
42
  // ============================================================================
@@ -1374,6 +1374,7 @@ const CHECKS = [
1374
1374
  checkErrorEnvelope, // Check 17 — BV1 R03 / task 1990: no route hand-rolls a flat error (ADR 0116)
1375
1375
  checkVocabularyWall, // Check 19 — ADR 0174 / goal 1000068 R07: "role" is a craft, never a rank
1376
1376
  checkGoalMembershipVocabulary, // Check 19b — ADR 0177 / task 1003013: the FOURTH "role" (goal membership)
1377
+ checkGovernmentRenameIdentity, // Check 19c — task 1003120: `X` → `X`, the rename arrow a sweep ate both sides of (the shape checks 19/19b are blind to)
1377
1378
  checkWriteRoutesValidated, // Check 18 — BV1 R12 / task 1999: every body-reading write route validates (ADR 0118)
1378
1379
  require('./fitness-ratchets.js').checkQualityRatchets, // Check 20 — task 1003129: quality budgets only tighten (mechanics + knip CI step live in fitness-ratchets.js)
1379
1380
  require('./doc-cli-guard.js').checkDocNamesRealCli, // Check 22 — task 1003165 / G01: a doc that names a CLI invocation must match the real CLI
@@ -1434,8 +1435,8 @@ function main() {
1434
1435
  if (require.main === module) main();
1435
1436
 
1436
1437
  module.exports = {
1437
- checkVocabularyWall, VOCAB_ALLOW,
1438
- checkGoalMembershipVocabulary, GOAL_VOCAB_SURFACE,
1438
+ checkVocabularyWall, VOCAB_ALLOW, VOCAB_CITATION, VOCAB_EXTS, vocabFilesToScan,
1439
+ checkGoalMembershipVocabulary, GOAL_VOCAB_SURFACE, checkGovernmentRenameIdentity, RENAME_IDENTITY_RE, // 19c — task 1003120
1439
1440
  runAll,
1440
1441
  resolvesToHostGame,
1441
1442
  checkCoreHostBoundary,
@@ -35,12 +35,21 @@
35
35
  // node scripts/gds/main-audit.js --strict # also block on unresolved authors
36
36
  // node scripts/gds/main-audit.js --db # resolve ranks from Postgres instead of the API
37
37
  // node scripts/gds/main-audit.js --require-roster # CI: an empty roster is a loud DID-NOT-RUN, not a vacuous green
38
+ //
39
+ // EXIT CONTRACT — never `process.exit()` here, use `cliExit(code)` + `return`
40
+ // (task 1003772). Every exit in this file lands after the roster request, and a
41
+ // hard exit mid-teardown aborts node natively on Windows (UV_HANDLE_CLOSING),
42
+ // printing the verdict and THEN dying at 127. That is worse here than in any
43
+ // other CLI: --verify-head IS the deploy gate, so the destroyed code makes a
44
+ // clean audit look like a refusal and a real blocker indistinguishable from a
45
+ // crash. Guarded by the static check in tests/cli_exit_no_abort.mjs.
46
+ // Codes: 0 = clean · 1 = blocker found · 2 = DID NOT RUN (never a pass).
38
47
 
39
48
  'use strict';
40
49
 
41
50
  const { execSync } = require('node:child_process');
42
51
  const ppc = require(require('node:path').join(__dirname, '..', '..', 'src', 'bongos', 'permission-path-check.js'));
43
- const { cliClient, API_BASE } = require('./cli-lib');
52
+ const { cliClient, cliExit, API_BASE } = require('./cli-lib');
44
53
 
45
54
  // ---------- pure analyzer (tested in tests/main_audit.mjs) ----------
46
55
 
@@ -319,14 +328,16 @@ async function main() {
319
328
  + `${Number.isFinite(depth) ? `${depth} commit(s)` : 'unreadable'} deep. `
320
329
  + 'Pass --range <A..B> or set OTB_AUDIT_RANGE. This is a check that did not execute, not a clean audit.'
321
330
  );
322
- process.exit(2);
331
+ cliExit(2);
332
+ return;
323
333
  }
324
334
  }
325
335
 
326
336
  const parsed = parseCommits(range);
327
337
  if (parsed.error) {
328
338
  console.error(`main-audit: ${parsed.error}`);
329
- process.exit(2);
339
+ cliExit(2);
340
+ return;
330
341
  }
331
342
  const rankMap = await fetchRankMap({ useDb });
332
343
  if (Object.keys(rankMap).length === 0) {
@@ -336,7 +347,8 @@ async function main() {
336
347
  // clean audit (the same false-green shape parseCommits guards against).
337
348
  if (process.argv.includes('--require-roster')) {
338
349
  console.error('main-audit: DID NOT RUN — the roster is unavailable (offline / no session / public leaderboard unreachable). An empty rank map can never produce a blocker, so exiting green would be a vacuous pass. Fix connectivity or drop --require-roster for a best-effort local read.');
339
- process.exit(2);
350
+ cliExit(2);
351
+ return;
340
352
  }
341
353
  console.error('main-audit: WARNING — could not load the GDS roster (offline / insufficient rank / no DB). Authors will be unresolved; run as a Metic+ builder or with --db (on the droplet) for a definitive audit.');
342
354
  }
@@ -346,7 +358,8 @@ async function main() {
346
358
  console.log(`\nMain audit (SEC #863) — range ${range}, ${parsed.commits.length} commit(s)`);
347
359
  if (result.findings.length === 0) {
348
360
  console.log(' ✓ clean — no sub-Metic edits to permission-sensitive paths.\n');
349
- process.exit(0);
361
+ cliExit(0);
362
+ return;
350
363
  }
351
364
  for (const f of result.findings) {
352
365
  const tag = f.severity === 'blocker' ? '\x1b[31m[BLOCKER]\x1b[0m' : '\x1b[33m[review]\x1b[0m';
@@ -364,16 +377,17 @@ async function main() {
364
377
  // verify-head is the deploy gate: any blocker (and, unless allowed, any
365
378
  // unresolved author) must stop the deploy.
366
379
  if (verifyHead) {
367
- process.exit(result.hardFail ? 1 : 0);
380
+ cliExit(result.hardFail ? 1 : 0);
381
+ return;
368
382
  }
369
383
  // report mode: non-zero on a real blocker so CI can fail.
370
- process.exit(result.hardFail ? 1 : 0);
384
+ cliExit(result.hardFail ? 1 : 0);
371
385
  }
372
386
 
373
387
  if (require.main === module) {
374
388
  main().catch((e) => {
375
389
  console.error(`main-audit: ${e.message}`);
376
- process.exit(2);
390
+ cliExit(2);
377
391
  });
378
392
  }
379
393
 
@@ -157,6 +157,31 @@ function templateSkeleton() {
157
157
  return lines.join('\n');
158
158
  }
159
159
 
160
+ // How the freshly-opened ratification window is described to its author.
161
+ //
162
+ // A NULL `closes_at` is the no-clock constitution, not a missing deadline —
163
+ // several pass rules run a sitting with no clock at all, and the window simply
164
+ // never expires. Printing the field raw turned that into "open until null" for
165
+ // every author on a clockless instance (task 1003654, caught five times by the
166
+ // R157 proof walk). So the rule decides the sentence, and the wording tracks
167
+ // the Board Room's own clockLine() (modules/hall-ui/public/board-room.js) —
168
+ // one vocabulary for one fact, whichever surface the author reads it on.
169
+ //
170
+ // ALL FOUR of config.js's PASS_RULES get a branch, `consent` included: it is
171
+ // not clock-safe (CLOCK_SAFE_PASS_RULES is majority + unanimous only), so a
172
+ // consent constitution is one of the likeliest to arrive here with no deadline
173
+ // at all. The default arm is for an unreadable rule, not for a known one.
174
+ function boardWindowLine(w) {
175
+ if (w.closes_at) return ` A board ratification window is open until ${w.closes_at}.`;
176
+ switch (w.pass_rule) {
177
+ case 'first_ratifier': return ' A board ratification window is open — no clock; it closes on the first vote.';
178
+ case 'consent': return ' A board ratification window is open — no clock; it stands unless someone objects.';
179
+ case 'majority': return ' A board ratification window is open — no clock; it waits for a majority.';
180
+ case 'unanimous': return ' A board ratification window is open — no clock; it waits for a unanimous yes.';
181
+ default: return ' A board ratification window is open — no clock; it stays open until the board decides.';
182
+ }
183
+ }
184
+
160
185
  // What the developer is told once it lands. The completeness score is the number
161
186
  // the R147 credit split and the board bar both read, so it is the headline.
162
187
  function renderDeveloped(data) {
@@ -168,7 +193,7 @@ function renderDeveloped(data) {
168
193
  ? ' You had the thought and you developed it.'
169
194
  : ` The thought stays credited to builder ${author_id}; you are recorded as its developer (${developer_id}).`);
170
195
  if (board_window) {
171
- lines.push(` A board ratification window is open until ${board_window.closes_at}.`);
196
+ lines.push(boardWindowLine(board_window));
172
197
  } else if (idea.rejection) {
173
198
  lines.push(` Not yet at the bar — thinnest section: ${idea.rejection.section_key}.`);
174
199
  if (idea.rejection.reason_md) lines.push(` ${idea.rejection.reason_md}`);
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.652'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.654'; // 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');
@@ -1,6 +1,13 @@
1
1
  // tests/cli_exit_no_abort.mjs — the GDS CLIs must not abort natively on exit
2
2
  // (task 1002734; the pre-push twin of this is tests/pre_push_hook.mjs, task 1002725).
3
3
  //
4
+ // Guards api.js AND main-audit.js (task 1003772). main-audit --verify-head IS the
5
+ // deploy gate (infra/deploy.sh calls it so a poisoned main never reaches
6
+ // production), so there the destroyed exit code is not cosmetic: the abort fires
7
+ // AFTER the verdict prints, and a caller reading the code cannot tell a clean
8
+ // audit from a real blocker. It reproduced 1/1 on Windows + Node 24 before the fix
9
+ // and went 5/5 clean after it.
10
+ //
4
11
  // THE BUG. On Windows + Node 24, calling process.exit() after the CLI has made a
5
12
  // request aborts the process before it can exit cleanly:
6
13
  //
@@ -26,13 +33,24 @@ import { test } from 'node:test';
26
33
  import { spawnSync, spawn } from 'node:child_process';
27
34
  import fs from 'node:fs';
28
35
  import path from 'node:path';
36
+ import { createRequire } from 'node:module';
29
37
  import { fileURLToPath } from 'node:url';
30
38
 
31
39
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
40
  const API = path.join(ROOT, 'scripts', 'gds', 'api.js');
41
+ const MAIN_AUDIT = path.join(ROOT, 'scripts', 'gds', 'main-audit.js');
33
42
  const CLI_LIB = path.join(ROOT, 'scripts', 'gds', 'cli-lib.js');
34
43
  const DEAD_BASE = 'http://127.0.0.1:9'; // discard port — connection refused, instantly
35
44
 
45
+ // Point a CLI at a base URL. DERIVED, not hardcoded: instance-config resolves
46
+ // <PREFIX>_API_BASE first and only then the legacy GDS_/OTB_/PMS_ spellings, so a
47
+ // literal here would (a) add to the legacy-GDS ratchet the rename is retiring and
48
+ // (b) silently stop steering the CLI on any instance whose brand pack sets a
49
+ // different prefix — the test would then hit the REAL API instead of the stub.
50
+ const { envPrefix } = createRequire(import.meta.url)(path.join(ROOT, 'src', 'instance-config.js'));
51
+ const API_BASE_ENV = `${envPrefix()}_API_BASE`;
52
+ const atBase = (url) => ({ [API_BASE_ENV]: url });
53
+
36
54
  // The native abort prints this and nothing else can be relied on; it is NOT a
37
55
  // JS-level error, so it never appears as a caught exception.
38
56
  const ABORT_RE = /Assertion failed|UV_HANDLE_CLOSING/i;
@@ -41,7 +59,7 @@ function runApi(args, env = {}) {
41
59
  return spawnSync(process.execPath, [API, ...args], {
42
60
  cwd: ROOT,
43
61
  encoding: 'utf8',
44
- env: { ...process.env, GDS_API_BASE: DEAD_BASE, ...env },
62
+ env: { ...process.env, ...atBase(DEAD_BASE), ...env },
45
63
  });
46
64
  }
47
65
 
@@ -104,7 +122,7 @@ test('api.js exits 0 after a SUCCESSFUL request — the path that aborted in the
104
122
  const { proc, port } = await startStub();
105
123
  try {
106
124
  for (let i = 0; i < 3; i++) {
107
- const r = runApi(['GET', '/api/gds/me', '--quiet'], { GDS_API_BASE: `http://127.0.0.1:${port}` });
125
+ const r = runApi(['GET', '/api/gds/me', '--quiet'], atBase(`http://127.0.0.1:${port}`));
108
126
  assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively after a 200:\n${r.stderr}`);
109
127
  assert.equal(r.status, 0, `a successful request must exit 0, got ${r.status} (127 = the abort); stderr: ${r.stderr}`);
110
128
  assert.match(r.stdout, /"ok"/, 'the JSON body must still reach stdout');
@@ -133,3 +151,92 @@ test('api.js routes its post-request exits through cliExit, not process.exit', (
133
151
  );
134
152
  assert.match(afterRequest, /cliExit\(/, 'the post-request paths must set the exit code via cliExit');
135
153
  });
154
+
155
+ // ---- main-audit.js — the deploy gate (task 1003772) ------------------------
156
+ // Ranges are always HEAD..HEAD: it resolves on every checkout including a shallow
157
+ // CI clone, audits zero commits, and still runs the WHOLE pipeline — fetchRankMap
158
+ // is called before the verdict, so the exit is a post-request exit either way.
159
+ //
160
+ // THE STATIC CHECK IS THE REAL NET, and this was measured, not assumed: with the
161
+ // hard exit deliberately put back, the static test failed while all four
162
+ // behavioural tests below still PASSED. The same run against a real range
163
+ // (`--range origin/main~15..origin/main`, 31 commits, a live roster call) aborted
164
+ // at exit 127. So the abort needs the full stack to line up — enough commits and a
165
+ // real request — which a network-free test cannot stage, exactly as the api.js
166
+ // note above says. The behavioural tests are still worth their runtime: they pin
167
+ // the exit CODES (0 / 2) that callers branch on, and their timeouts catch the
168
+ // documented conversion trap where a leaked timer turns the crash into a hang.
169
+
170
+ function runAudit(args, env = {}) {
171
+ return spawnSync(process.execPath, [MAIN_AUDIT, ...args], {
172
+ cwd: ROOT,
173
+ encoding: 'utf8',
174
+ timeout: 60_000, // dropping a hard exit trades a crash for a HANG if a timer leaks
175
+ env: { ...process.env, ...atBase(DEAD_BASE), ...env },
176
+ });
177
+ }
178
+
179
+ test('main-audit.js routes every exit through cliExit, not process.exit', () => {
180
+ const src = fs.readFileSync(MAIN_AUDIT, 'utf8');
181
+ const stripped = src.replace(/\/\/[^\n]*/g, '');
182
+ // Unlike api.js, main-audit has no pre-request exits worth keeping separate —
183
+ // all seven sites were converted, so the guard is simply "none left".
184
+ assert.ok(
185
+ !/process\.exit\s*\(/.test(stripped),
186
+ 'no process.exit() may remain in main-audit.js — that is what aborts node on Windows, '
187
+ + 'and this script IS the deploy gate whose exit code the abort destroys'
188
+ );
189
+ assert.match(src, /cliExit\(/, 'the exit paths must set the exit code via cliExit');
190
+ });
191
+
192
+ test('main-audit.js exits cleanly after a FAILED roster request', () => {
193
+ const r = runAudit(['--range', 'HEAD..HEAD']);
194
+ assert.notEqual(r.signal, 'SIGTERM', 'timed out — a leaked timer turned the crash into a hang');
195
+ assert.doesNotMatch(r.stderr || '', ABORT_RE, `native abort on the audit path:\n${r.stderr}`);
196
+ assert.equal(r.status, 0, `a clean audit must exit 0, not ${r.status} (127 = the abort)`);
197
+ assert.match(r.stdout, /✓ clean/, 'the verdict must still reach stdout');
198
+ });
199
+
200
+ test('main-audit.js --verify-head exits 0 on a clean audit — the deploy gate reads this code', () => {
201
+ // The gate path specifically: infra/deploy.sh treats non-zero as "do not deploy",
202
+ // so an abort here blocks a deploy that nothing is actually wrong with.
203
+ for (let i = 0; i < 3; i++) {
204
+ const r = runAudit(['--verify-head', '--range', 'HEAD..HEAD']);
205
+ assert.notEqual(r.signal, 'SIGTERM', `run ${i + 1} timed out — leaked handle`);
206
+ assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively:\n${r.stderr}`);
207
+ assert.equal(r.status, 0, `run ${i + 1} exited ${r.status}; stderr: ${r.stderr}`);
208
+ }
209
+ });
210
+
211
+ test('main-audit.js --require-roster still exits 2 when the roster is unreachable', () => {
212
+ // The post-request DID-NOT-RUN path. Converting it to cliExit must not turn a
213
+ // refusal into a vacuous green — exit 2 is what stops CI calling this a pass.
214
+ const r = runAudit(['--require-roster', '--range', 'HEAD..HEAD']);
215
+ assert.notEqual(r.signal, 'SIGTERM', 'timed out — leaked handle');
216
+ assert.doesNotMatch(r.stderr || '', ABORT_RE);
217
+ assert.equal(r.status, 2, `an unavailable roster must exit 2, got ${r.status}`);
218
+ assert.match(r.stderr, /DID NOT RUN/);
219
+ });
220
+
221
+ test('main-audit.js reports an unresolvable range as exit 2 (pre-request path)', () => {
222
+ const r = runAudit(['--range', 'bogusref..HEAD']);
223
+ assert.doesNotMatch(r.stderr || '', ABORT_RE);
224
+ assert.equal(r.status, 2, 'an unresolvable range is exit 2, and must not be mistaken for clean');
225
+ assert.match(r.stderr, /DID NOT RUN/);
226
+ });
227
+
228
+ test('main-audit.js exits 0 after a SUCCESSFUL roster request — the teardown that aborted in the wild', async () => {
229
+ // A refused connection tears down far less machinery than a 200. The wild repro
230
+ // was a successful roster call, so this is the case that actually matters.
231
+ const { proc, port } = await startStub();
232
+ try {
233
+ for (let i = 0; i < 3; i++) {
234
+ const r = runAudit(['--range', 'HEAD..HEAD'], atBase(`http://127.0.0.1:${port}`));
235
+ assert.notEqual(r.signal, 'SIGTERM', `run ${i + 1} timed out — leaked handle`);
236
+ assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively after a 200:\n${r.stderr}`);
237
+ assert.equal(r.status, 0, `run ${i + 1} exited ${r.status} (127 = the abort); stderr: ${r.stderr}`);
238
+ }
239
+ } finally {
240
+ proc.kill();
241
+ }
242
+ });
@@ -46,18 +46,113 @@ test('a planted "governance" on the government surface REDS the build', () => {
46
46
  // Plant a line into a real government-surface file, run the check, restore.
47
47
  // Uses the real file rather than a fixture so the test exercises the same path
48
48
  // resolution the CI gate does.
49
- function withPlantedLine(line) {
49
+ function withPlantedLine(line, relParts = ['modules', 'government', 'resolver.js'], check = 'checkVocabularyWall') {
50
50
  const fs = require('node:fs');
51
- const target = path.join(ROOT, 'modules', 'government', 'resolver.js');
51
+ const target = path.join(ROOT, ...relParts);
52
52
  const original = fs.readFileSync(target, 'utf8');
53
53
  try {
54
54
  fs.writeFileSync(target, `${original}\n${line}\n`);
55
- return fitness.checkVocabularyWall().violations;
55
+ return fitness[check]().violations;
56
56
  } finally {
57
57
  fs.writeFileSync(target, original);
58
58
  }
59
59
  }
60
60
 
61
+ const MD = ['modules', 'government', 'CLAUDE.md'];
62
+
63
+ // ---------- markdown is inside the wall (task 1003120) ----------
64
+ //
65
+ // The gap this closes: a nested CLAUDE.md is INSTRUCTION — it loads on demand
66
+ // for every agent working in the module, and the agent that reads the wrong
67
+ // vocabulary there writes it into the code next. It sat outside the very rule
68
+ // it describes, which is how it came to teach the INVERSE of ADR 0174 and
69
+ // survive a criterion review (task 1003118).
70
+
71
+ test('the wall scans markdown — the doc that TEACHES the rule is inside it', () => {
72
+ assert.ok(fitness.VOCAB_EXTS.has('.md'),
73
+ 'markdown must be scanned: the highest-leverage vocabulary surface is the nested '
74
+ + 'CLAUDE.md an agent reads before writing code, not the code itself');
75
+ const scanned = fitness.vocabFilesToScan().map((f) => f.rel);
76
+ assert.ok(scanned.includes('modules/government/CLAUDE.md'),
77
+ `the module's own nested doc must be in scope; scanned: ${scanned.join(', ')}`);
78
+ });
79
+
80
+ test('a planted "role" in a government MARKDOWN file REDS the build', () => {
81
+ const planted = withPlantedLine('A custom role may hold this permission.', MD);
82
+ assert.ok(planted.some((v) => /CLAUDE\.md.*"role"/.test(v)),
83
+ 'a doc that teaches the banned vocabulary must be caught — otherwise adding '
84
+ + '.md to VOCAB_EXTS bought nothing');
85
+ });
86
+
87
+ // ---------- the citation rule is an exemption, not a hole ----------
88
+ //
89
+ // Preferred over three more VOCAB_ALLOW entries (which would have taken the cap
90
+ // of 5 to its limit) because the exemption belongs to the CITATION, not to
91
+ // whichever file happens to hold it. So it has to be narrow, and both halves of
92
+ // that are load-bearing: it lets the rule be NAMED, and nothing else.
93
+
94
+ test('a line that CITES the rule may say the banned word', () => {
95
+ const planted = withPlantedLine('On this surface "role" is banned — see ADR 0174.', MD);
96
+ assert.ok(!planted.some((v) => /CLAUDE\.md/.test(v)),
97
+ 'meta-commentary about the ban has to be able to name it, or the doc could '
98
+ + 'not state the rule it enforces');
99
+ });
100
+
101
+ test('the citation rule does NOT excuse the rest of the line', () => {
102
+ // Same file, same banned word, no citation: still caught. Without this the
103
+ // exemption could be silently widened to "this file is fine" and stay green.
104
+ const planted = withPlantedLine('A custom role may hold this permission.', MD);
105
+ assert.ok(planted.some((v) => /CLAUDE\.md/.test(v)),
106
+ 'a line with no citation must still red — an exemption that covers the file '
107
+ + 'rather than the sentence is an allowlist wearing a regex');
108
+ });
109
+
110
+ test('the allowlist did not grow to absorb the markdown surface', () => {
111
+ // The whole point of the citation rule: bringing .md into scope surfaced three
112
+ // legitimate lines, and NONE of them cost an allowlist slot.
113
+ assert.ok(fitness.VOCAB_ALLOW.length <= 4,
114
+ `the allowlist grew to ${fitness.VOCAB_ALLOW.length} when .md came into scope. `
115
+ + 'The citation rule exists so it would not have to.');
116
+ });
117
+
118
+ // ---------- check 19c: the rename arrow a sweep ate both sides of ----------
119
+ //
120
+ // The defect checks 19 and 19b are structurally blind to. Task 1003118 ran a
121
+ // find-and-replace over a doc DESCRIBING the rename and swept both sides of
122
+ // every pair: `governance` → `government` became `government` → `government`.
123
+ // No banned word survives that edit, so the wall passed while the doc taught
124
+ // the next agent that the old and new names were the same word.
125
+
126
+ test('the government surface carries no identity rename arrows today', () => {
127
+ const r = fitness.checkGovernmentRenameIdentity();
128
+ assert.equal(r.ok, true,
129
+ `an \`X\` → \`X\` arrow is on the surface:\n${r.violations.join('\n')}`);
130
+ assert.ok(r.note.includes('file(s) scanned'), 'the check must report what it scanned');
131
+ });
132
+
133
+ test('a planted `X` → `X` pair REDS the build', () => {
134
+ const planted = withPlantedLine('The sweep renamed `ranks.js` → `ranks.js` here.', MD, 'checkGovernmentRenameIdentity');
135
+ assert.ok(planted.some((v) => /ranks\.js/.test(v)),
136
+ 'an arrow between two identical names states nothing and is only ever the '
137
+ + 'fossil of a sweep — it must be caught');
138
+ });
139
+
140
+ test('a REAL rename arrow is left alone', () => {
141
+ // The check must not punish the sentence it exists to protect: a doc
142
+ // describing the rename correctly names two different things.
143
+ const planted = withPlantedLine('The sweep renamed `governance_001` → `government_001` here.', MD, 'checkGovernmentRenameIdentity');
144
+ assert.equal(planted.length, 0,
145
+ `a legitimate A → B rename must pass, or the rule teaches docs not to describe `
146
+ + `renames at all:\n${planted.join('\n')}`);
147
+ });
148
+
149
+ test('the rename-identity check has no allowlist to erode', () => {
150
+ const r = fitness.checkGovernmentRenameIdentity();
151
+ assert.ok(/no allowlist/.test(r.note),
152
+ 'an arrow between identical names has no legitimate form, so there is nothing '
153
+ + 'to excuse — and no slot for a future exemption to grow into');
154
+ });
155
+
61
156
  // ---------- the allowlist is small, reasoned, and real ----------
62
157
 
63
158
  test('every allowlist entry carries a written reason', () => {
@@ -382,6 +382,49 @@ await test('the CLI names both parties, and never prints "undefined"', () => {
382
382
  assert.doesNotMatch(out, /undefined|null/);
383
383
  });
384
384
 
385
+ // task 1003654 — the R157 proof walk read "A board ratification window is open
386
+ // until null" five times on cloudbongos.com. A NULL closes_at is the no-clock
387
+ // constitution, NOT a broken window, so the copy has to name how the sitting
388
+ // closes. One case per pass rule, plus the unknown-rule fallback, because the
389
+ // whole failure was one branch that assumed a deadline always exists.
390
+ for (const [rule, expected] of [
391
+ ['first_ratifier', /no clock; it closes on the first vote/],
392
+ ['consent', /no clock; it stands unless someone objects/],
393
+ ['majority', /no clock; it waits for a majority/],
394
+ ['unanimous', /no clock; it waits for a unanimous yes/],
395
+ [null, /no clock; it stays open until the board decides/],
396
+ ]) {
397
+ await test(`a clockless ${rule || 'unknown-rule'} window says how it closes, never "until null"`, () => {
398
+ const out = cli.renderDeveloped({
399
+ idea: { id: '77', completeness_score: 88 },
400
+ author_id: '9', developer_id: '42', self_developed: false,
401
+ board_window: { item_id: '3', closes_at: null, pass_rule: rule },
402
+ });
403
+ assert.match(out, /ratification window is open/);
404
+ assert.match(out, expected);
405
+ assert.doesNotMatch(out, /until null|undefined/);
406
+ });
407
+ }
408
+
409
+ // Held equal to government's own rule list, the way board.js's FULL_IDEA_PASS_BAR
410
+ // is held equal to economy's — the CLI cannot require() a sibling module, so the
411
+ // test is where the two lists meet. Without this, adding a fifth pass rule
412
+ // silently drops its authors onto the unreadable-rule fallback.
413
+ await test('EVERY pass rule government declares gets its own sentence, not the fallback', () => {
414
+ const { PASS_RULES } = require('../modules/government/config.js');
415
+ const fallback = cli.renderDeveloped({
416
+ idea: { id: '77', completeness_score: 88 }, author_id: '9', developer_id: '42', self_developed: false,
417
+ board_window: { item_id: '3', closes_at: null, pass_rule: '__no_such_rule__' },
418
+ });
419
+ for (const rule of PASS_RULES) {
420
+ const out = cli.renderDeveloped({
421
+ idea: { id: '77', completeness_score: 88 }, author_id: '9', developer_id: '42', self_developed: false,
422
+ board_window: { item_id: '3', closes_at: null, pass_rule: rule },
423
+ });
424
+ assert.notEqual(out, fallback, `pass rule '${rule}' falls through to the unreadable-rule fallback`);
425
+ }
426
+ });
427
+
385
428
  await test('self-development reads as one person, not a handoff to yourself', () => {
386
429
  const out = cli.renderDeveloped({ idea: { id: '1', completeness_score: 70 }, author_id: '9', developer_id: '9', self_developed: true });
387
430
  assert.match(out, /You had the thought and you developed it/);
@@ -25,7 +25,7 @@ const api = require('../src/module-api.js');
25
25
 
26
26
  // ---- the government port stub (registered BEFORE the routes load) ----------
27
27
  let rejectionAnswer = null; // what latestRejectionForIdea returns
28
- let windowAnswer = { opened: true, item: { id: '31', closes_at: null } };
28
+ let windowAnswer = { opened: true, item: { id: '31', closes_at: null, constitution: { pass_rule: 'first_ratifier' } } };
29
29
  const portCalls = [];
30
30
  if (!api.hasProvider('government')) {
31
31
  api.registerProvider('government', {
@@ -84,7 +84,7 @@ const fullIdea = (over = {}) => ({
84
84
  let passed = 0, failed = 0;
85
85
  async function test(name, fn) {
86
86
  queries = []; portCalls.length = 0;
87
- rejectionAnswer = null; windowAnswer = { opened: true, item: { id: '31', closes_at: null } };
87
+ rejectionAnswer = null; windowAnswer = { opened: true, item: { id: '31', closes_at: null, constitution: { pass_rule: 'first_ratifier' } } };
88
88
  try { await fn(); passed++; console.log(` ok ${name}`); }
89
89
  catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
90
90
  }
@@ -113,7 +113,11 @@ await test('a board objection outranks the mechanical flag, and an ANSWERED sect
113
113
  assert.equal(r.payload.flagged_section, 'purpose', 'the human objection wins over the weakest-section flag');
114
114
  assert.equal(r.payload.flagged_section_changed, true);
115
115
  assert.equal(r.payload.rejection_source, 'board');
116
- assert.deepEqual(r.payload.board_window, { item_id: '31', closes_at: null }, 'a new window opened');
116
+ // The pass rule rides along with the deadline (task 1003654): a clockless
117
+ // sitting reports closes_at null, and without the rule a caller cannot tell
118
+ // that apart from a broken window — which is how "open until null" reached
119
+ // authors in the first place.
120
+ assert.deepEqual(r.payload.board_window, { item_id: '31', closes_at: null, pass_rule: 'first_ratifier' }, 'a new window opened, and it says how it closes');
117
121
  });
118
122
 
119
123
  await test('reworking everything EXCEPT the flagged section reads changed:false', async () => {