@bongos/core 1.19.617 → 1.19.619

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ // tests/backlog_review.mjs — the /backlog-review bucket rules and the
2
+ // status-derived verb offer (task 1003746).
3
+ //
4
+ // These are the two claims the skill rests on, so they are the two things a test
5
+ // has to execute rather than paraphrase: (1) a row waiting on a live TRIGGER is
6
+ // never presented as waiting on a PERSON, and (2) the walk never offers a verb
7
+ // the route will refuse. The `skill names a command it never runs` learning
8
+ // (r155-documented-nonfunctional-command) is why the last test here actually
9
+ // requires and calls the module the SKILL.md tells a builder to run.
10
+ import { strict as assert } from 'node:assert';
11
+ import { test } from 'node:test';
12
+ import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const br = require('../scripts/gds/backlog-review.js');
20
+
21
+ const task = (over = {}) => ({
22
+ id: 1, status: 'backlog', kind: 'feature', title: 't', created_at: '2026-01-01T00:00:00Z',
23
+ blocked_by: [], ...over,
24
+ });
25
+
26
+ test('a row with no unsatisfied dependency is awaiting a nod', () => {
27
+ const p = br.partitionBacklog([task({ id: 7 })]);
28
+ assert.deepEqual(p.nod.map((t) => t.id), [7]);
29
+ assert.equal(p.depGated.length, 0);
30
+ assert.equal(p.stranded.length, 0);
31
+ });
32
+
33
+ test('a satisfied dependency does NOT make a row dep-gated', () => {
34
+ const p = br.partitionBacklog([task({ id: 8, blocked_by: [{ id: 2, status: 'shipped', satisfied: true }] })]);
35
+ assert.deepEqual(p.nod.map((t) => t.id), [8], 'a shipped dep is not a gate');
36
+ assert.equal(p.depGated.length, 0);
37
+ });
38
+
39
+ test('a live unsatisfied dependency is dep-gated and is NOT walked as a nod', () => {
40
+ const p = br.partitionBacklog([task({ id: 9, blocked_by: [{ id: 3, status: 'ready', satisfied: false }] })]);
41
+ assert.deepEqual(p.depGated.map((t) => t.id), [9]);
42
+ assert.equal(p.nod.length, 0, 'the migration-163 trigger owns this row, not a human');
43
+ });
44
+
45
+ test('an ABANDONED dependency makes a row stranded, not merely dep-gated', () => {
46
+ const p = br.partitionBacklog([task({ id: 10, blocked_by: [{ id: 4, status: 'abandoned', satisfied: false }] })]);
47
+ assert.deepEqual(p.stranded.map((t) => t.id), [10]);
48
+ assert.equal(p.depGated.length, 0, 'stranded wins: "your gate is dead" is the actionable fact');
49
+ assert.deepEqual(p.stranded[0].dead_deps.map((d) => d.id), [4], 'the dead edges ride along for the hint');
50
+ });
51
+
52
+ test('stranded wins over spike too — a dead gate outranks the backlog-by-convention rule', () => {
53
+ const p = br.partitionBacklog([task({ id: 11, kind: 'spike', blocked_by: [{ id: 5, status: 'abandoned', satisfied: false }] })]);
54
+ assert.deepEqual(p.stranded.map((t) => t.id), [11]);
55
+ assert.equal(p.spikes.length, 0);
56
+ });
57
+
58
+ test('a spike lives at backlog by convention and is never walked', () => {
59
+ const p = br.partitionBacklog([task({ id: 12, kind: 'spike' })]);
60
+ assert.deepEqual(p.spikes.map((t) => t.id), [12]);
61
+ assert.equal(p.nod.length, 0, 'migration 020: spikes stay in backlog by convention');
62
+ });
63
+
64
+ test('rows that are not at backlog are ignored entirely', () => {
65
+ const p = br.partitionBacklog([task({ id: 13, status: 'ready' }), task({ id: 14, status: 'shipped' }), task({ id: 15, status: 'blocked' })]);
66
+ assert.equal(p.nod.length + p.depGated.length + p.stranded.length + p.spikes.length, 0);
67
+ });
68
+
69
+ test('partitionBacklog tolerates junk without throwing', () => {
70
+ assert.doesNotThrow(() => br.partitionBacklog(null));
71
+ assert.doesNotThrow(() => br.partitionBacklog([null, undefined, {}]));
72
+ const p = br.partitionBacklog([task({ id: 16, blocked_by: null })]);
73
+ assert.deepEqual(p.nod.map((t) => t.id), [16], 'a null blocked_by is no gate, not a crash');
74
+ });
75
+
76
+ test('both walked buckets are ordered oldest-first so an interrupted walk resumes in place', () => {
77
+ const p = br.partitionBacklog([
78
+ task({ id: 30, created_at: '2026-03-01T00:00:00Z' }),
79
+ task({ id: 31, created_at: '2026-01-01T00:00:00Z' }),
80
+ task({ id: 32, created_at: '2026-02-01T00:00:00Z' }),
81
+ ]);
82
+ assert.deepEqual(p.nod.map((t) => t.id), [31, 32, 30]);
83
+ });
84
+
85
+ // --- goal grouping --------------------------------------------------------
86
+
87
+ test('rows are clustered by goal, not left interleaved', () => {
88
+ const g = br.groupByGoal([
89
+ task({ id: 40, goal_id: 1, created_at: '2026-01-01T00:00:00Z' }),
90
+ task({ id: 41, goal_id: 2, created_at: '2026-01-02T00:00:00Z' }),
91
+ task({ id: 42, goal_id: 1, created_at: '2026-01-03T00:00:00Z' }),
92
+ ]);
93
+ assert.equal(g.length, 2, 'two goals, two groups');
94
+ assert.deepEqual(g[0].rows.map((t) => t.id), [40, 42], 'goal 1 rows are adjacent');
95
+ assert.deepEqual(g[1].rows.map((t) => t.id), [41]);
96
+ });
97
+
98
+ test('goals are ordered by their OWN oldest row, so the longest-ignored goal comes first', () => {
99
+ const g = br.groupByGoal([
100
+ task({ id: 50, goal_id: 'new', created_at: '2026-05-01T00:00:00Z' }),
101
+ task({ id: 51, goal_id: 'old', created_at: '2026-01-01T00:00:00Z' }),
102
+ task({ id: 52, goal_id: 'new', created_at: '2026-05-02T00:00:00Z' }),
103
+ ]);
104
+ assert.deepEqual(g.map((x) => x.goal_id), ['old', 'new']);
105
+ });
106
+
107
+ test('rows inside a group stay oldest-first', () => {
108
+ const g = br.groupByGoal([
109
+ task({ id: 60, goal_id: 1, created_at: '2026-03-01T00:00:00Z' }),
110
+ task({ id: 61, goal_id: 1, created_at: '2026-01-01T00:00:00Z' }),
111
+ task({ id: 62, goal_id: 1, created_at: '2026-02-01T00:00:00Z' }),
112
+ ]);
113
+ assert.deepEqual(g[0].rows.map((t) => t.id), [61, 62, 60]);
114
+ });
115
+
116
+ test('a goal-less row is walkable in its own group, never dropped', () => {
117
+ const g = br.groupByGoal([task({ id: 70, goal_id: null }), task({ id: 71, goal_id: 5 })]);
118
+ const total = g.reduce((n, x) => n + x.rows.length, 0);
119
+ assert.equal(total, 2, 'every input row reaches a group');
120
+ assert.ok(g.some((x) => x.goal_id === '(no goal)'));
121
+ });
122
+
123
+ test('grouping loses no rows and duplicates none, on a mixed batch', () => {
124
+ const rows = [];
125
+ for (let i = 0; i < 25; i++) rows.push(task({ id: 100 + i, goal_id: i % 4, created_at: `2026-01-${String((i % 28) + 1).padStart(2, '0')}T00:00:00Z` }));
126
+ const g = br.groupByGoal(rows);
127
+ const ids = g.flatMap((x) => x.rows.map((t) => t.id)).sort((a, b) => a - b);
128
+ assert.equal(new Set(ids).size, 25);
129
+ assert.deepEqual(ids, rows.map((t) => t.id).sort((a, b) => a - b));
130
+ });
131
+
132
+ test('groupByGoal tolerates junk', () => {
133
+ assert.deepEqual(br.groupByGoal(null), []);
134
+ assert.deepEqual(br.groupByGoal([null, undefined]), []);
135
+ });
136
+
137
+ // --- the verb offer -------------------------------------------------------
138
+
139
+ test('a backlog row is offered promote, never demote', () => {
140
+ const v = br.verbsFor(task({ status: 'backlog' }));
141
+ assert.ok(v.includes('promote'));
142
+ assert.ok(!v.includes('demote'), 'POST /tasks/:id/demote requires status=ready — it would 409 cannot_demote here');
143
+ });
144
+
145
+ test('a ready row is offered demote, never promote', () => {
146
+ const v = br.verbsFor(task({ status: 'ready' }));
147
+ assert.ok(v.includes('demote'));
148
+ assert.ok(!v.includes('promote'), 'POST /tasks/:id/promote accepts only backlog|blocked|abandoned');
149
+ });
150
+
151
+ test('blocked and abandoned rows accept promote (the blocked→ready and un-abandon paths)', () => {
152
+ assert.ok(br.verbsFor(task({ status: 'blocked' })).includes('promote'));
153
+ assert.ok(br.verbsFor(task({ status: 'abandoned' })).includes('promote'), 'ADR 0152: abandoned → backlog restore');
154
+ });
155
+
156
+ test('water is offered on every row and kill is never offered on a terminal one', () => {
157
+ for (const status of ['backlog', 'ready', 'blocked', 'active', 'shipped', 'abandoned']) {
158
+ assert.ok(br.verbsFor(task({ status })).includes('water'), `water missing for ${status}`);
159
+ }
160
+ assert.ok(!br.verbsFor(task({ status: 'shipped' })).includes('kill'));
161
+ assert.ok(!br.verbsFor(task({ status: 'abandoned' })).includes('kill'));
162
+ assert.ok(br.verbsFor(task({ status: 'backlog' })).includes('kill'));
163
+ });
164
+
165
+ test('the API limit the script asks for is within the route cap of 1000', () => {
166
+ assert.ok(br.MAX_API_LIMIT <= 1000, 'routes/tasks.js: "limit must be a positive integer up to 1000"');
167
+ assert.ok(br.MAX_API_LIMIT > 0);
168
+ });
169
+
170
+ // --- the skill names a command that exists and runs -----------------------
171
+
172
+ test('SKILL.md names scripts/gds/backlog-review.js and that file loads and partitions', () => {
173
+ const skill = fs.readFileSync(path.join(ROOT, '.claude', 'skills', 'backlog-review', 'SKILL.md'), 'utf8');
174
+ assert.match(skill, /scripts\/gds\/backlog-review\.js/, 'the skill must name the script it runs');
175
+ // Executed, not paraphrased: the module the prose points at really answers.
176
+ const p = br.partitionBacklog([task({ id: 99 })]);
177
+ assert.deepEqual(p.nod.map((t) => t.id), [99]);
178
+ });
179
+
180
+ test('SKILL.md does not tell a builder to demote a backlog row', () => {
181
+ const skill = fs.readFileSync(path.join(ROOT, '.claude', 'skills', 'backlog-review', 'SKILL.md'), 'utf8');
182
+ assert.ok(!/\[p\]rune/.test(skill), 'prune/demote is a guaranteed 409 on a backlog row');
183
+ });
@@ -0,0 +1,168 @@
1
+ // tests/cli_sessions.mjs — one session slot became one per instance (task 1003741).
2
+ //
3
+ // THE BUG. The CLI resolved its session path from the BRANDING PACK, which is read out of whatever
4
+ // checkout you happen to be standing in. That gave it exactly one slot, with two failure modes:
5
+ //
6
+ // • standalone (the public @cloudbongos/cli's whole situation) the brand cannot resolve, so every
7
+ // instance shared ~/.config/cloudbongos/gds-session.json and `bongos login <other>` DESTROYED
8
+ // the session you already had. The owner's own config dir carries a hand-made
9
+ // `gds-session.hermeslines-clobber-2026-08-12.bak.json` from exactly this.
10
+ // • in-repo the session landed in ~/.config/<slug>/ where the standalone CLI could never find it.
11
+ //
12
+ // The store is keyed by instance host at a FIXED anchor, so the same instance resolves to the same
13
+ // file from either direction. The per-brand gds-session.json stays the ACTIVE pointer, untouched,
14
+ // so every existing reader behaves identically — that back-compat is asserted here too.
15
+
16
+ import test from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+ import fs from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { createRequire } from 'node:module';
23
+ import { spawnSync } from 'node:child_process';
24
+
25
+ const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const require = createRequire(import.meta.url);
27
+ const ic = require(path.join(REPO_ROOT, 'src', 'instance-config.js'));
28
+ const lib = require(path.join(REPO_ROOT, 'scripts', 'gds', 'cli-lib.js'));
29
+
30
+ // SESSION_PATH is frozen at module load from os.homedir(), so anything touching the real save path
31
+ // runs in a child with its own HOME.
32
+ function inSandbox(body) {
33
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-sess-'));
34
+ try {
35
+ const code = `const R=${JSON.stringify(REPO_ROOT)};
36
+ const lib=require(R+'/scripts/gds/cli-lib.js');
37
+ const ic=require(R+'/src/instance-config.js');
38
+ const fs=require('node:fs'), path=require('node:path');
39
+ (async()=>{ ${body} })().catch((e)=>{ console.error('ERR '+e.message); process.exit(1); });`;
40
+ const r = spawnSync(process.execPath, ['-e', code], { encoding: 'utf8', env: { ...process.env, HOME: home } });
41
+ return { home, out: `${r.stdout || ''}${r.stderr || ''}`, status: r.status };
42
+ } finally {
43
+ fs.rmSync(home, { recursive: true, force: true });
44
+ }
45
+ }
46
+
47
+ // ── 1. The key and the anchor ───────────────────────────────────────────────────────────────
48
+
49
+ test('a session is keyed by the instance host', () => {
50
+ assert.equal(lib.sessionHostKey('https://cloudbongos.com'), 'cloudbongos.com');
51
+ assert.equal(lib.sessionHostKey('https://hermeslines-marketing.cloudbongos.com'), 'hermeslines-marketing.cloudbongos.com');
52
+ assert.equal(lib.sessionHostKey('http://localhost:3000'), 'localhost_3000');
53
+ });
54
+
55
+ test('an unkeyable base is stored nowhere, never under a guess', () => {
56
+ for (const bad of ['', null, undefined, 'not a url', '/etc/passwd', '../../escape']) {
57
+ assert.equal(lib.sessionHostKey(bad), null, `should refuse ${JSON.stringify(bad)}`);
58
+ assert.equal(lib.sessionStorePath(bad), null);
59
+ }
60
+ });
61
+
62
+ test('no host can escape the store directory', () => {
63
+ // The key becomes a filename, so a traversal in a hostile api_base must not reach outside.
64
+ for (const b of ['https://cloudbongos.com', 'http://localhost:3000']) {
65
+ const p = lib.sessionStorePath(b);
66
+ assert.equal(path.dirname(p), lib.sessionStoreDir(), `${b} escaped the store dir`);
67
+ assert.ok(!path.basename(p).includes('/') && !path.basename(p).includes('\\'));
68
+ }
69
+ });
70
+
71
+ test('the store anchor is FIXED, not brand-derived — that is the whole fix', () => {
72
+ // A brand-derived anchor is what split in-repo from standalone in the first place. If someone
73
+ // "helpfully" routes this through configHome() again, the two diverge and the bug is back.
74
+ assert.equal(lib.sessionStoreDir(), path.join(os.homedir(), '.config', ic.FALLBACK_DIR, 'instances'));
75
+ const src = fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'cli-lib.js'), 'utf8');
76
+ const body = src.slice(src.indexOf('function sessionStoreDir()'));
77
+ const fn = body.slice(0, body.indexOf('\n}') + 2);
78
+ assert.ok(!/configHome|configDirName|safeBrand/.test(fn), `sessionStoreDir must not read the brand:\n${fn}`);
79
+ });
80
+
81
+ // ── 2. A login never destroys another instance's session ────────────────────────────────────
82
+
83
+ test('signing into a second instance PRESERVES the first — including one written before the store existed', () => {
84
+ const { out } = inSandbox(`
85
+ // A session from before this feature: an active pointer, nothing in the store.
86
+ const active = ic.configPath('gds-session.json');
87
+ fs.mkdirSync(path.dirname(active), { recursive: true });
88
+ fs.writeFileSync(active, JSON.stringify({ token:'TOKEN-A', api_base:'https://a.example.com',
89
+ builder:{github_login:'someone'} }), { mode: 0o600 });
90
+
91
+ await lib.saveSession({ token:'TOKEN-B', api_base:'https://b.example.com', builder:{github_login:'someone'} });
92
+
93
+ const rescued = await lib.loadStoredSession('https://a.example.com');
94
+ const stored = await lib.loadStoredSession('https://b.example.com');
95
+ const pointer = JSON.parse(fs.readFileSync(active,'utf8'));
96
+ console.log(JSON.stringify({
97
+ firstSurvived: !!(rescued && rescued.token === 'TOKEN-A'),
98
+ secondStored: !!(stored && stored.token === 'TOKEN-B'),
99
+ activeIsSecond: pointer.api_base === 'https://b.example.com',
100
+ hosts: lib.listStoredSessions().map(s => s.host),
101
+ }));`);
102
+ const r = JSON.parse(out.trim().split('\n').pop());
103
+ assert.equal(r.firstSurvived, true, 'the first instance\'s session was destroyed — the bug is back');
104
+ assert.equal(r.secondStored, true);
105
+ assert.equal(r.activeIsSecond, true, 'the active pointer must follow the newest sign-in');
106
+ assert.deepEqual(r.hosts, ['a.example.com', 'b.example.com']);
107
+ });
108
+
109
+ test('the ACTIVE pointer still lands exactly where every existing reader looks', () => {
110
+ // In-repo skills, hooks and the dev box all read gds-session.json in the configured dir. The
111
+ // store is additive; if this moved, every one of them would silently stop finding a session.
112
+ const { out } = inSandbox(`
113
+ await lib.saveSession({ token:'T', api_base:'https://a.example.com', builder:{github_login:'x'} });
114
+ console.log(JSON.stringify({ wroteActive: fs.existsSync(ic.configPath('gds-session.json')) }));`);
115
+ assert.equal(JSON.parse(out.trim().split('\n').pop()).wroteActive, true);
116
+ });
117
+
118
+ test('re-saving the SAME instance is idempotent and files nothing extra', () => {
119
+ const { out } = inSandbox(`
120
+ const s = { token:'T1', api_base:'https://a.example.com', builder:{github_login:'x'} };
121
+ await lib.saveSession(s);
122
+ await lib.saveSession({ ...s, token:'T2' }); // e.g. fetch-art-key rewriting one field
123
+ const cur = await lib.loadStoredSession('https://a.example.com');
124
+ console.log(JSON.stringify({ hosts: lib.listStoredSessions().map(h=>h.host), token: cur.token }));`);
125
+ const r = JSON.parse(out.trim().split('\n').pop());
126
+ assert.deepEqual(r.hosts, ['a.example.com'], 'a same-instance re-save must not fan out');
127
+ assert.equal(r.token, 'T2');
128
+ });
129
+
130
+ test('session files are owner-only', () => {
131
+ const { out } = inSandbox(`
132
+ await lib.saveSession({ token:'T', api_base:'https://a.example.com', builder:{github_login:'x'} });
133
+ const f = fs.statSync(lib.sessionStorePath('https://a.example.com')).mode & 0o777;
134
+ const d = fs.statSync(lib.sessionStoreDir()).mode & 0o777;
135
+ console.log(JSON.stringify({ file: f.toString(8), dir: d.toString(8) }));`);
136
+ const r = JSON.parse(out.trim().split('\n').pop());
137
+ assert.equal(r.file, '600', 'a session file holds a bearer token');
138
+ assert.equal(r.dir, '700');
139
+ });
140
+
141
+ test('a corrupt store entry is skipped, never fatal', () => {
142
+ const { out } = inSandbox(`
143
+ await lib.saveSession({ token:'T', api_base:'https://a.example.com', builder:{github_login:'x'} });
144
+ fs.writeFileSync(path.join(lib.sessionStoreDir(),'broken.json'), '{ not json');
145
+ console.log(JSON.stringify({ hosts: lib.listStoredSessions().map(h=>h.host) }));`);
146
+ assert.deepEqual(JSON.parse(out.trim().split('\n').pop()).hosts, ['a.example.com']);
147
+ });
148
+
149
+ // ── 3. Switching back ───────────────────────────────────────────────────────────────────────
150
+
151
+ test('login VERIFIES a stored token before trusting it, and can be forced past', () => {
152
+ const src = fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'login.js'), 'utf8');
153
+ // A stored token may be expired or revoked. Reinstating one unchecked would leave the builder
154
+ // "signed in" to a session every later command then fails on.
155
+ assert.match(src, /loadStoredSession\(base\)/);
156
+ assert.match(src, /\/api\/gds\/me`, \{ headers: \{ Authorization/);
157
+ assert.match(src, /if \(me\.ok && me\.data && me\.data\.builder\)/);
158
+ assert.match(src, /--force/, 'there must be a way to sign in as somebody else');
159
+ });
160
+
161
+ test('login names the other instances and how to move between them', () => {
162
+ const src = fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'login.js'), 'utf8');
163
+ assert.match(src, /function logOtherInstances/);
164
+ assert.match(src, /Also signed in to/);
165
+ // Terminal reader: no slash command may appear in that guidance (task 1003730's rule).
166
+ const fnStart = src.indexOf('function logOtherInstances');
167
+ assert.ok(!/\/builder-/.test(src.slice(fnStart, fnStart + 700)), 'no slash command in terminal guidance');
168
+ });
@@ -0,0 +1,159 @@
1
+ // tests/government_config_roots.mjs — the constitution's two files come from two
2
+ // different roots (task 1003739, ADR 0268).
3
+ //
4
+ // THE BUG THIS PINS, because it was silent for two weeks and cost a ratified
5
+ // constitutional amendment. `modules/government/config.js` resolved BOTH the
6
+ // neutral starter and the instance pack from `path.resolve(__dirname, '..',
7
+ // '..')`, under a comment saying the two roots coincided "today". True in a
8
+ // single checkout. False on cloudbongos.com, which runs a PINNED core: there
9
+ // __dirname is inside node_modules/@cloudbongos/core, so the INSTANCE pack
10
+ // resolved to a path inside the core package — one no host authors, and one
11
+ // `npm ci` erases. The board ratified consent/metic+/1440 on 2026-08-25,
12
+ // `applyBoardAmendment` wrote it into node_modules, the next core upgrade
13
+ // deleted it, and GET /government/constitution went on answering the day-one
14
+ // monarchy while its own history showed the amendment had passed.
15
+ //
16
+ // NOTHING THREW. Both paths exist, both are writable, and an absent instance
17
+ // pack is a legal state (a vanilla instance has none), so the read fell through
18
+ // to the neutral file exactly as designed. That is why this is a STRUCTURAL
19
+ // test rather than a behavioural one: there is no error to assert on, only the
20
+ // question of which root each path was built from.
21
+ //
22
+ // The single-checkout answers are asserted too, and deliberately: they are what
23
+ // makes this fix byte-identical for a non-split instance, and a future "tidy-up"
24
+ // that re-collapses the two roots must fail here rather than in production two
25
+ // weeks later.
26
+ import assert from 'node:assert/strict';
27
+ import { test } from 'node:test';
28
+ import { createRequire } from 'node:module';
29
+ import fs from 'node:fs';
30
+ import os from 'node:os';
31
+ import path from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ const require = createRequire(import.meta.url);
35
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
36
+ const GOV = path.join(ROOT, 'modules', 'government');
37
+ const CONFIG_PATH = path.join(GOV, 'config.js');
38
+ const config = require(CONFIG_PATH);
39
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
40
+
41
+ // config.js resolves its two paths AT LOAD (deliberately — see its header), so
42
+ // exercising a different instance root means re-loading it with the env set.
43
+ // The cache delete is scoped to this one module; instance-config resolves the
44
+ // env per call, so nothing else needs evicting.
45
+ function loadWithInstanceRoot(dir) {
46
+ const KEY = 'CLOUDBONGOS_INSTANCE_ROOT';
47
+ const before = process.env[KEY];
48
+ process.env[KEY] = dir;
49
+ delete require.cache[require.resolve(CONFIG_PATH)];
50
+ try { return require(CONFIG_PATH); }
51
+ finally {
52
+ if (before === undefined) delete process.env[KEY]; else process.env[KEY] = before;
53
+ delete require.cache[require.resolve(CONFIG_PATH)];
54
+ require(CONFIG_PATH); // restore the module-cache entry every other test shares
55
+ }
56
+ }
57
+
58
+ // ── the doorway ─────────────────────────────────────────────────────────────
59
+
60
+ test('the module doorway exposes BOTH roots — a module cannot tell them apart without it', () => {
61
+ // ADR 0083: a module never requires a core internal, so until this task the
62
+ // only way to reach a root from inside a module was to re-derive it from
63
+ // __dirname — which is precisely the derivation that was wrong.
64
+ assert.equal(typeof api.resolveCoreRoot, 'function');
65
+ assert.equal(typeof api.resolveInstanceRoot, 'function');
66
+ assert.equal(api.resolveCoreRoot(), ROOT, 'the core root is this checkout');
67
+ });
68
+
69
+ // ── the split ───────────────────────────────────────────────────────────────
70
+
71
+ test('the NEUTRAL starter follows the CORE root and the INSTANCE pack follows the INSTANCE root', () => {
72
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gov-roots-'));
73
+ try {
74
+ const split = loadWithInstanceRoot(tmp);
75
+ assert.equal(split.NEUTRAL_PATH, path.join(ROOT, 'config', 'government.neutral.json'),
76
+ 'the neutral starter is CORE content and must not follow the host');
77
+ assert.equal(split.INSTANCE_PATH, path.join(tmp, 'config', 'government.json'),
78
+ 'the instance pack is HOST content — the bug was this landing inside the core package');
79
+ assert.notEqual(path.dirname(split.INSTANCE_PATH), path.dirname(split.NEUTRAL_PATH),
80
+ 'a split instance must resolve the two to DIFFERENT directories');
81
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
82
+ });
83
+
84
+ test('in a single checkout the two roots coincide, exactly as before the fix', () => {
85
+ // The byte-identical claim for every non-split instance, asserted rather than
86
+ // asserted-in-prose. If this ever fails, the fix changed behaviour for the
87
+ // ordinary case and that is a regression, not a refinement.
88
+ assert.equal(config.NEUTRAL_PATH, path.join(ROOT, 'config', 'government.neutral.json'));
89
+ assert.equal(config.INSTANCE_PATH, path.join(ROOT, 'config', 'government.json'));
90
+ });
91
+
92
+ test('a host instance pack at the INSTANCE root is actually READ', () => {
93
+ // The other half of the same bug, and the half a host would have hit first:
94
+ // before the fix, an instance that hand-authored config/government.json in its
95
+ // own repo — the documented way to set a constitution — was silently ignored,
96
+ // because the reader was looking inside node_modules.
97
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gov-roots-'));
98
+ try {
99
+ fs.mkdirSync(path.join(tmp, 'config'));
100
+ fs.writeFileSync(path.join(tmp, 'config', 'government.json'), JSON.stringify({
101
+ board: { membership: 'rank:metic+', pass_rule: 'unanimous', window_minutes: 1440, close_early_on_full_turnout: true },
102
+ }));
103
+ const split = loadWithInstanceRoot(tmp);
104
+ const { board } = split.loadGovernmentConfig({ instancePath: split.INSTANCE_PATH });
105
+ assert.equal(board.pass_rule, 'unanimous');
106
+ assert.equal(board.membership, 'rank:metic+');
107
+ assert.equal(board.window_minutes, 1440);
108
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
109
+ });
110
+
111
+ // ── the write ───────────────────────────────────────────────────────────────
112
+
113
+ test('a ratified amendment creates the config directory rather than dying on ENOENT', () => {
114
+ // Now that the write targets the INSTANCE root, the directory genuinely may
115
+ // not exist: an instance that has never been branded and never amended has no
116
+ // config/ of its own. Before the fix the write always landed beside the core's
117
+ // own config/, which is why this never came up — and a ratified amendment
118
+ // lost to a missing directory is the same failure as the one being fixed.
119
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gov-roots-'));
120
+ try {
121
+ const target = path.join(tmp, 'nested', 'config', 'government.json');
122
+ assert.equal(fs.existsSync(path.dirname(target)), false, 'sanity: the directory is absent');
123
+ const applied = config.applyBoardAmendment(
124
+ { membership: 'rank:archon', pass_rule: 'unanimous', window_minutes: 1440, close_early_on_full_turnout: true },
125
+ { instancePath: target },
126
+ );
127
+ assert.equal(applied.pass_rule, 'unanimous');
128
+ assert.deepEqual(JSON.parse(fs.readFileSync(target, 'utf8')).board, applied,
129
+ 'what was written back is what was returned');
130
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
131
+ });
132
+
133
+ test('applying an amendment preserves the rest of the instance pack', () => {
134
+ // Unchanged behaviour, re-pinned here because the write now happens in a
135
+ // directory the core does not own: an instance pack holds rank templates too,
136
+ // and a constitution change must not take them with it.
137
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gov-roots-'));
138
+ try {
139
+ const target = path.join(tmp, 'config', 'government.json');
140
+ fs.mkdirSync(path.dirname(target));
141
+ fs.writeFileSync(target, JSON.stringify({ ranks: { metic: { label: 'Trusted' } }, board: { pass_rule: 'consent' } }));
142
+ config.applyBoardAmendment(
143
+ { membership: 'rank:archon', pass_rule: 'majority', window_minutes: 1440, close_early_on_full_turnout: true },
144
+ { instancePath: target },
145
+ );
146
+ const after = JSON.parse(fs.readFileSync(target, 'utf8'));
147
+ assert.equal(after.board.pass_rule, 'majority');
148
+ assert.deepEqual(after.ranks, { metic: { label: 'Trusted' } }, 'the rank templates survive');
149
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
150
+ });
151
+
152
+ test('this checkout still ships NO instance pack — the vanilla-state guard', () => {
153
+ // The pre-existing guard (a test once wrote a real config/government.json into
154
+ // the checkout), kept because the write path moved. It is now doubly
155
+ // load-bearing: with the instance root defaulting to process.cwd(), a stray
156
+ // file here would be read by every test that follows.
157
+ assert.equal(fs.existsSync(path.join(ROOT, 'config', 'government.json')), false,
158
+ 'config/government.json must not exist in the core checkout — a test wrote one once');
159
+ });
@@ -0,0 +1,123 @@
1
+ // tests/government_constitution_divergence.mjs — the detector for a ratified
2
+ // amendment that did not take (task 1003739, ADR 0268).
3
+ //
4
+ // The path bug that caused the live failure is fixed at its source, in
5
+ // config.js, and pinned by tests/government_config_roots.mjs. THIS is the
6
+ // detector for the CLASS: any future reason a ratified amendment fails to reach
7
+ // the live constitution — a failed write, a rolled-back deploy, a hand edit that
8
+ // reverts one — now surfaces on the constitution view instead of being invisible.
9
+ //
10
+ // WHY A DETECTOR AND NOT JUST THE FIX. On 2026-08-25 this board ratified an
11
+ // amendment and the constitution went on answering the day-one monarchy. The API
12
+ // had BOTH facts in ONE payload the whole time — a passed amendment in `history`,
13
+ // a contradicting `board` beside it — and simply never compared them. Two weeks
14
+ // of sittings were decided under a rule the board had voted to replace, and the
15
+ // only reason anyone noticed was someone reading the JSON by hand.
16
+ //
17
+ // IT REPORTS, IT NEVER HEALS, and that is a decision rather than an omission.
18
+ // The instance pack is also the file a human editing the constitution touches
19
+ // (config.js §applyBoardAmendment), so a silent re-apply would revert a
20
+ // legitimate hand edit with no way to tell the two apart — trading a visible
21
+ // wrong constitution for an unstoppable one.
22
+ import assert from 'node:assert/strict';
23
+ import { test } from 'node:test';
24
+ import { createRequire } from 'node:module';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
+ const GOV = path.join(ROOT, 'modules', 'government');
31
+ const board = require(path.join(GOV, 'board.js'));
32
+ const db = require(path.join(GOV, 'db.js'));
33
+
34
+ const { constitutionDivergence, constitutionView } = board;
35
+
36
+ const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
37
+ // The REAL proposal from cloudbongos.com's amendment 1, item 11 — the sitting
38
+ // that passed and never took. Kept verbatim so this test is the live failure
39
+ // rather than a sketch of it.
40
+ const PASSED_2026_08_25 = {
41
+ item_id: '11', amendment_id: '1', outcome: 'passed', closed_at: '2026-08-25T16:53:45.655Z',
42
+ proposed: { pass_rule: 'consent', membership: 'rank:metic+', window_minutes: 1440, close_early_on_full_turnout: true },
43
+ };
44
+
45
+ test('THE LIVE FAILURE: a passed amendment beside a contradicting constitution is named', () => {
46
+ const d = constitutionDivergence(MONARCHY, [PASSED_2026_08_25]);
47
+ assert.ok(d, 'the divergence must be reported, not null');
48
+ assert.equal(d.item_id, '11');
49
+ assert.equal(d.amendment_id, '1');
50
+ const byField = Object.fromEntries(d.fields.map((f) => [f.field, f]));
51
+ assert.deepEqual(Object.keys(byField).sort(), ['membership', 'pass_rule', 'window_minutes']);
52
+ assert.deepEqual(byField.pass_rule, { field: 'pass_rule', ratified: 'consent', in_force: 'first_ratifier' });
53
+ assert.deepEqual(byField.membership, { field: 'membership', ratified: 'rank:metic+', in_force: 'rank:archon' });
54
+ assert.deepEqual(byField.window_minutes, { field: 'window_minutes', ratified: 1440, in_force: null });
55
+ assert.equal(d.fields.some((f) => f.field === 'close_early_on_full_turnout'), false,
56
+ 'a field that AGREES is not reported — the report is the delta, not the whole block');
57
+ });
58
+
59
+ test('a constitution that IS the last ratified one reports nothing', () => {
60
+ // The healthy answer, and the one the hall renders nothing for. A detector
61
+ // that fires on a working system is a detector people learn to ignore.
62
+ const inForce = { membership: 'rank:metic+', pass_rule: 'consent', window_minutes: 1440, close_early_on_full_turnout: true };
63
+ assert.equal(constitutionDivergence(inForce, [PASSED_2026_08_25]), null);
64
+ });
65
+
66
+ test('a RETURNED amendment is not a decision and never diverges', () => {
67
+ // The whole meaning of 'returned'. Comparing against it would report a
68
+ // divergence on every rejected proposal — i.e. on the system working.
69
+ const returned = { ...PASSED_2026_08_25, outcome: 'returned' };
70
+ assert.equal(constitutionDivergence(MONARCHY, [returned]), null);
71
+ // …and the newest PASSED one still stands, with later returns above it.
72
+ const d = constitutionDivergence(MONARCHY, [returned, PASSED_2026_08_25]);
73
+ assert.ok(d);
74
+ assert.equal(d.item_id, '11');
75
+ });
76
+
77
+ test('a board that has never amended anything reports nothing', () => {
78
+ assert.equal(constitutionDivergence(MONARCHY, []), null);
79
+ assert.equal(constitutionDivergence(MONARCHY, null), null);
80
+ assert.equal(constitutionDivergence(MONARCHY, [{ outcome: 'passed' }]), null, 'a row with no proposal is not a claim');
81
+ });
82
+
83
+ test('the comparison is against the SANITIZED proposal, not the raw one', () => {
84
+ // applyBoardAmendment sanitizes on write, so comparing the RAW proposal would
85
+ // report a phantom divergence on any field the sanitizer legitimately
86
+ // normalises — and a warning that can never be cleared is worse than none.
87
+ // 999999 is past MAX_WINDOW_MINUTES, so it sanitizes to the default (null),
88
+ // which is what is in force: nothing to report.
89
+ const junkWindow = { ...PASSED_2026_08_25, proposed: { ...PASSED_2026_08_25.proposed, window_minutes: 999999 } };
90
+ const inForce = { membership: 'rank:metic+', pass_rule: 'consent', window_minutes: null, close_early_on_full_turnout: true };
91
+ assert.equal(constitutionDivergence(inForce, [junkWindow]), null);
92
+ });
93
+
94
+ test('the newest passed amendment wins — one it superseded is not compared', () => {
95
+ const older = {
96
+ item_id: '4', amendment_id: '2', outcome: 'passed', closed_at: '2026-08-01T00:00:00Z',
97
+ proposed: { pass_rule: 'majority', membership: 'rank:archon', window_minutes: 90, close_early_on_full_turnout: true },
98
+ };
99
+ // listAmendmentHistory returns newest-first, so the 08-25 row is the standing
100
+ // decision and the older one is history, not a claim on the present.
101
+ const d = constitutionDivergence(MONARCHY, [PASSED_2026_08_25, older]);
102
+ assert.equal(d.item_id, '11');
103
+ assert.equal(d.ratified.pass_rule, 'consent', 'not the older majority proposal');
104
+ });
105
+
106
+ test('the constitution view carries it, and this checkout is healthy', async () => {
107
+ // Wired end to end: the view computes the divergence from the history it
108
+ // already read, so there is no second query that could disagree with the rows
109
+ // shown beside it.
110
+ const originals = { listAmendmentHistory: db.listAmendmentHistory, countActiveBuilders: db.countActiveBuilders };
111
+ try {
112
+ Object.assign(db, { listAmendmentHistory: async () => [], countActiveBuilders: async () => 4 });
113
+ const clean = await constitutionView();
114
+ assert.equal(clean.divergence, null, 'a board with no amendments has nothing to warn about');
115
+
116
+ Object.assign(db, { listAmendmentHistory: async () => [PASSED_2026_08_25] });
117
+ const diverged = await constitutionView();
118
+ assert.ok(diverged.divergence, 'the live failure, reproduced through the real view');
119
+ assert.equal(diverged.divergence.item_id, '11');
120
+ assert.equal(diverged.board.pass_rule, 'first_ratifier', 'the view still reports what is actually in force');
121
+ assert.equal(diverged.divergence.ratified.pass_rule, 'consent', 'beside what was ratified');
122
+ } finally { Object.assign(db, originals); }
123
+ });
@@ -34,6 +34,7 @@ const PUBLISHED_SURFACE = [
34
34
  'llmCache', // BV1.R73: shared LLM-cost cache (1.6.0; tracked domain leak)
35
35
  'llmPricing', // BV1.R77: shared token→USD pricing (1.7.0; tracked domain leak — economy carve)
36
36
  'branding', 'clientBranding', 'userAgent', 'resolveEnv', 'readConfigFileSync', 'configHome', 'configPath', // userAgent added 1.12.0 (BV1.R86; github-push UA)
37
+ 'resolveCoreRoot', 'resolveInstanceRoot', // added by task 1003739 (ADR 0268): the two ADR 0108 §1 roots. A module reading a HOST-owned file could not tell them apart without these, and re-deriving one from __dirname is right in a single checkout and silently wrong on a standalone instance — which is how a RATIFIED constitutional amendment came to be written into node_modules and erased by the next core upgrade. Core content → resolveCoreRoot(); host content → resolveInstanceRoot(); src/branding.js is the pattern
37
38
  'resolveStaleClaimHours', 'staleClaimMinutes', // added by task 1003476 (ADR 0226; goal 1000072): the PROJECT's stale-claim timer — the sweep that releases a silent claim and the Archon roster that reports one read the same number through here, after eighteen hours of disagreeing (6 vs 24)
38
39
  'resolveRotDays', 'projectSettings', // added by task 1003279 (ADR 0232; goal 1000072): the ROT timer + the live knob store behind it. A DIFFERENT question from the stale-claim timer above — days not hours, DB not env, work nobody picked up rather than a builder who went quiet, and it only ever ASKS a human where that one ACTS. Both ride the doorway so no module hardcodes either.
39
40
  'joinabilityMode', // added by task 1003044 (ADR 0194; the carrier after 1.19.374): the project's membership door, one reader for the sign-in gate AND the onboarding module's public POST