@bongos/core 1.19.1006 → 1.19.1007

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,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.1006",
6
- "core_contract": "1.19.1006",
7
- "source_commit": "9aa3406769d294ded80ce0c3ba750f987330c9f1",
5
+ "core_version": "1.19.1007",
6
+ "core_contract": "1.19.1007",
7
+ "source_commit": "f375a1f9bc2733b268373a2bddc0f56ac98f9545",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-24T17:44:53.834Z",
9
+ "built_at": "2026-09-24T18:44:31.419Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 521,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2416,
14
+ "functional_verbatim": 2417,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2963,
20
- "tree_sha256": "9c17e2477189d3ab186f9bb68d58ed6355e6473026cd3b701acaf41a22897a3c",
19
+ "file_count": 2964,
20
+ "tree_sha256": "a3092242ab5f0f40863591a9a46cf3bf27d8cebffb66fb921ef5c6759118894a",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2722,7 +2722,7 @@
2722
2722
  {
2723
2723
  "path": "docs/module-api-changelog.md",
2724
2724
  "mode": "0000644",
2725
- "sha256": "7b93d0bb0e325f2f1ce5811a41e0c346510355724bf4af9d3dd7ecb3c33c055c"
2725
+ "sha256": "74968b3cf51dab6e30a15e597d9683ff4bcb028a91cf41dc84df8b17e27e56a4"
2726
2726
  },
2727
2727
  {
2728
2728
  "path": "docs/modules-contract.md",
@@ -8217,12 +8217,12 @@
8217
8217
  {
8218
8218
  "path": "package-lock.json",
8219
8219
  "mode": "0000644",
8220
- "sha256": "3421e2b4d18b53a29636822c8fd2e01124efd53166ac6aca19481e0eaf7ae227"
8220
+ "sha256": "0eb3e0e2315f534fde69db06708e503d819c7709193f3d1852e3f743fd1dcf82"
8221
8221
  },
8222
8222
  {
8223
8223
  "path": "package.json",
8224
8224
  "mode": "0000644",
8225
- "sha256": "67d920a83d31e1c8665729f683394520d13b150d123e36a5f85434f2998a69f4"
8225
+ "sha256": "73343900249b135947edc3411bfc6607afd19aa0d258156533a18201947313bd"
8226
8226
  },
8227
8227
  {
8228
8228
  "path": "public-docs/index.html",
@@ -10267,7 +10267,7 @@
10267
10267
  {
10268
10268
  "path": "src/module-api.js",
10269
10269
  "mode": "0000644",
10270
- "sha256": "4fea542180e4134fcb0fe0e02322d27caee9cdf0d1003d63e5d43c68ad3ffccd"
10270
+ "sha256": "6cebfae980a10138a4ca32ddf081f3fa7c1c06789d7698f59b6175d916cf4b08"
10271
10271
  },
10272
10272
  {
10273
10273
  "path": "src/module-loader/catalog.js",
@@ -12549,6 +12549,11 @@
12549
12549
  "mode": "0000644",
12550
12550
  "sha256": "a3fc2cb4ab314b39ebbd5e0f909b494f8cc10e904d81ac4f896da5de6c671bc4"
12551
12551
  },
12552
+ {
12553
+ "path": "tests/ideator_large_dead_thought_proof.mjs",
12554
+ "mode": "0000644",
12555
+ "sha256": "7b13dcdfb215890100a37f7a88722e4dd937b761fe9fa527b1a549de7016b7e8"
12556
+ },
12552
12557
  {
12553
12558
  "path": "tests/ideator_traceable_proof.mjs",
12554
12559
  "mode": "0000644",
@@ -2486,5 +2486,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2486
2486
  landed since 1.19.1004 with no explicit bump. run 36032559593. (task 1002620)
2487
2487
  1.19.1006 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2488
2488
  landed since 1.19.1005 with no explicit bump. run 36036374443. (task 1002620)
2489
+ 1.19.1007 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2490
+ landed since 1.19.1006 with no explicit bump. run 36043267244. (task 1002620)
2489
2491
  ---------------------------------------------------------------------------
2490
2492
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1006",
3
+ "version": "1.19.1007",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1006",
9
+ "version": "1.19.1007",
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.1006",
3
+ "version": "1.19.1007",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -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.1006'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1007'; // 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');
@@ -0,0 +1,399 @@
1
+ // tests/ideator_large_dead_thought_proof.mjs
2
+ //
3
+ // THE PROOF for criterion `ideator-sky-tells-the-truth` (goal 1000095,
4
+ // BONGOS-V2), written under task 1004106 (BV2.R27). Sibling of
5
+ // tests/ideator_traceable_proof.mjs (R21), tests/ideator_fork_pays_proof.mjs
6
+ // (R23) and tests/ideator_full_idea_shapes_space_proof.mjs (R25), and it keeps
7
+ // their standard: a proof whose only possible outcome is "confirmed" is not a
8
+ // proof. Every claim was probed by breaking the real code — see
9
+ // docs/governance/bongos-v2-ideator-sky-tells-the-truth-proof.md.
10
+ //
11
+ // THE SINGLE SCENARIO THE CRITERION EXISTS FOR: a thought that caused
12
+ // substantial work which the project has since removed shows at FULL SIZE and
13
+ // ZERO BRIGHTNESS, and is distinguishable at a glance from a thought that never
14
+ // caused anything at all. If those two render alike, the criterion is not met
15
+ // however green the build is — that distinction is the entire reason size and
16
+ // brightness are two channels instead of one number.
17
+ //
18
+ // WHAT THIS ADDS OVER tests/hall_sky.mjs, which already has a case named "A
19
+ // LARGE DEAD THOUGHT RENDERS LARGE AND DEAD". That case is correct and stays.
20
+ // But it builds its rows BY HAND — its `remnant()` fixture literally writes
21
+ // `size: { credits: 180, scale: 0.86 }` beside `brightness: 0` — so it asserts
22
+ // that a renderer handed a preserved size draws a preserved size. The claim the
23
+ // criterion actually makes is one layer below that: **that the size IS
24
+ // preserved**, by the server, after the removal. A brightness read that dimmed
25
+ // the size as it dimmed the light would leave every assertion in that suite
26
+ // green, because the suite told the fixture what the size was. That comment in
27
+ // hall_sky.mjs says as much — the large-dead case is "the thing BV2.R27 goes on
28
+ // to prove".
29
+ //
30
+ // SO THIS RUNS THE WHOLE PATH, NOT ITS MIDDLE:
31
+ //
32
+ // credit_log rows ──▶ real ideaSizes ──▶ real ideaBrightnesses (real removals
33
+ // port) ──▶ real resolveIdeaState ──▶ the row the route emits ──▶ REAL sky.js
34
+ // in a vm ──▶ the geometry a reader sees
35
+ //
36
+ // Nothing between the ledger and the glyph is hand-written. `scale` is COMPUTED
37
+ // from the ledger after the removal is confirmed, never asserted into place, so
38
+ // "size never falls" is a measurement here rather than a fixture's promise.
39
+ //
40
+ // DB-free: pool.query is patched on the doorway and the store throws on any SQL
41
+ // it does not recognise, so a renamed column cannot turn this green by
42
+ // answering nothing.
43
+ //
44
+ // Run: node tests/ideator_large_dead_thought_proof.mjs
45
+
46
+ import assert from 'node:assert/strict';
47
+ import { createRequire } from 'node:module';
48
+ import { makeRunner, settle, bootSkyPage } from './helpers.mjs';
49
+
50
+ const require = createRequire(import.meta.url);
51
+ const api = require('../src/module-api.js');
52
+
53
+ const { test, summary } = makeRunner();
54
+ const drain = async (n = 20) => { for (let i = 0; i < n; i += 1) await settle(); };
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // The ledger. One table, read by the real economy SQL.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ // credit_log rows: { reason, task_id, delta }. `reason` carries the idea id the
61
+ // way the real writer embeds it — `idea.credit.task_ship:<ideaId>:<taskId>`.
62
+ let creditLog = [];
63
+
64
+ function answer(sql, params) {
65
+ const s = sql.replace(/\s+/g, ' ').trim();
66
+
67
+ if (/FROM credit_log/i.test(s) && /split_part\(reason, ':', 2\)/i.test(s)) {
68
+ const wanted = new Set((params[0] || []).map(String));
69
+ // GROUP BY (idea_id, task_id), exactly as the real read does.
70
+ const groups = new Map();
71
+ for (const row of creditLog) {
72
+ const parts = String(row.reason).split(':');
73
+ // The stream-1 prefix filter. A stream-2 row (`idea.credit.full_pass:…`)
74
+ // is the thought, not the work it caused, and must not be summed here.
75
+ if (!String(row.reason).startsWith('idea.credit.task_ship:')) continue;
76
+ const ideaId = parts[1];
77
+ if (!wanted.has(String(ideaId))) continue;
78
+ const key = `${ideaId}\u0000${row.task_id}`;
79
+ const acc = groups.get(key) || { idea_id: ideaId, task_id: row.task_id, credits: 0, payments: 0 };
80
+ acc.credits += Number(row.delta) || 0;
81
+ acc.payments += 1;
82
+ groups.set(key, acc);
83
+ }
84
+ return { rows: [...groups.values()] };
85
+ }
86
+
87
+ // The grade read the size path makes: an idea's grade is what prices the
88
+ // hops the ledger never paid for. Every idea here is graded `quick` and has
89
+ // an empty descent, so nothing is estimated — but the read still happens and
90
+ // the store still has to answer it honestly.
91
+ if (/FROM idea_inbox i LEFT JOIN ideas_grades g/i.test(s)) {
92
+ return { rows: (params[0] || []).map((id) => ({ id: String(id), grade: 'quick', completeness: null })) };
93
+ }
94
+
95
+ throw new Error(`the store does not recognise this SQL — a hop moved:\n ${s.slice(0, 200)}`);
96
+ }
97
+
98
+ api.pool.query = async (sql, params = []) => answer(sql, params);
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // The ports. `lifecycle.removals` is the one the whole criterion turns on: a
102
+ // removal is a PERSON'S confirmed verdict (ADR 0313), never a diff.
103
+ // ---------------------------------------------------------------------------
104
+
105
+ // taskId -> { proposalTaskId, paths }. Reassigned per case.
106
+ let removals = new Map();
107
+
108
+ const removalsPort = {
109
+ confirmedRemovalsForTasks: async (taskIds) => {
110
+ const out = new Map();
111
+ for (const id of taskIds) {
112
+ const hit = removals.get(String(id));
113
+ if (hit) out.set(String(id), hit);
114
+ }
115
+ return out;
116
+ },
117
+ };
118
+
119
+ if (!api.hasProvider('lifecycle.removals')) api.registerProvider('lifecycle.removals', removalsPort);
120
+ // The descent walk. These ideas' payouts are all direct roots, which is the live
121
+ // shape (see the record: no multi-hop descent exists on this instance), so the
122
+ // walk has nothing to add and an empty descent is the honest answer.
123
+ if (!api.hasProvider('ideas.descent')) {
124
+ api.registerProvider('ideas.descent', { descentForIdeas: async () => new Map() });
125
+ }
126
+
127
+ const economy = require('../modules/economy/idea-size.js');
128
+ const { compressIdeaSize } = economy;
129
+ const { ideaBrightnesses } = require('../modules/economy/idea-brightness.js');
130
+ const { attachStateToTrail } = require('../modules/ideas/idea-state.js');
131
+ const { attachSizeToTrail } = require('../modules/ideas/trail-size.js');
132
+
133
+ // The `reward` port, carrying the REAL economy functions. Both attachers reach
134
+ // economy only through this door (ADR 0083), so registering the real ones is
135
+ // what makes the two hops below run production code rather than a stand-in.
136
+ if (!api.hasProvider('reward')) {
137
+ api.registerProvider('reward', {
138
+ ideaSizes: economy.ideaSizes,
139
+ ideaBrightnesses,
140
+ // An idea whose payouts are all direct roots has no descent rows to price.
141
+ descentRowsFromTrail: () => [],
142
+ });
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // THE SERVER HALF, assembled by real code only. This is what the route emits.
147
+ // ---------------------------------------------------------------------------
148
+
149
+ // Build the trail rows for a cast of ideas, running every server hop for real.
150
+ async function trailRowsFor(cast) {
151
+ // The rows as the trail read builds them BEFORE size and state exist. Every
152
+ // field below is an ordinary idea_inbox fact; `size` and `state` are left off
153
+ // entirely, because the whole point is that the server puts them there.
154
+ const rows = cast.map((c) => ({
155
+ id: c.id,
156
+ title: c.title,
157
+ kind: 'feature',
158
+ status: c.status,
159
+ captured_at: '2026-09-01T00:00:00.000Z',
160
+ triage_note: null,
161
+ promoted_to_task_id: null,
162
+ promoted_task_title: null,
163
+ promoted_task_status: null,
164
+ merged_into_idea_id: null,
165
+ merged_into_idea_title: null,
166
+ lineage_tasks: [],
167
+ descent_tasks: [],
168
+ }));
169
+
170
+ // THE TWO REAL HOPS, in the order the route composes them: size first (state
171
+ // reuses the byTask breakdown size computed), then state.
172
+ await attachSizeToTrail(rows);
173
+ await attachStateToTrail(rows);
174
+ return rows;
175
+ }
176
+
177
+ const page = (ideas) => ({ ideas, total: ideas.length, offset: 0, limit: 100 });
178
+
179
+ // Read a glyph's geometry back off the markup the real renderer wrote.
180
+ function glyphOf(html, ideaId) {
181
+ const re = new RegExp(`data-idea="${ideaId}"[\\s\\S]*?<span class="sky-glyph"([^>]*)>`);
182
+ const m = re.exec(html);
183
+ if (!m) return null;
184
+ const attrs = m[1];
185
+ return {
186
+ object: (/data-object="([^"]+)"/.exec(attrs) || [])[1] || null,
187
+ d: Number((/--d:(\d+)px/.exec(attrs) || [])[1]),
188
+ lit: Number((/--lit:([\d.]+)/.exec(attrs) || [])[1]),
189
+ unknownSize: /data-size="unknown"/.test(attrs),
190
+ };
191
+ }
192
+ const labelOf = (html, ideaId) =>
193
+ (new RegExp(`data-idea="${ideaId}"[\\s\\S]*?aria-label="([^"]*)"`).exec(html) || [])[1] || '';
194
+
195
+ // Render a cast end to end and hand back the glyphs, so a case states the
196
+ // SCENARIO and never the plumbing.
197
+ async function render(cast) {
198
+ const rows = await trailRowsFor(cast);
199
+ const p = bootSkyPage({ pages: [page(rows)] });
200
+ await drain();
201
+ const html = p.field();
202
+ return { html, rows, glyph: (id) => glyphOf(html, id), label: (id) => labelOf(html, id), page: p };
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // The cast. Substantial work = four shipped tasks paying 240 credits, which is
207
+ // the shape the criterion's sentence describes.
208
+ // ---------------------------------------------------------------------------
209
+
210
+ // The four hops, as WORTHS. The task ids are minted per idea below, because a
211
+ // removal is keyed by TASK: two ideas sharing a task id would share its death,
212
+ // and the 'untouched twin' this proof leans on would not be untouched. (That
213
+ // is not hypothetical — the first version of this file shared them, and the
214
+ // twin came back dark.)
215
+ const WORTHS = [120, 60, 40, 20];
216
+ const TOTAL = WORTHS.reduce((a, b) => a + b, 0);
217
+ // Idea 601 owns tasks 6011..6014, idea 602 owns 6021..6024, and so on.
218
+ const tasksOf = (ideaId) => WORTHS.map((delta, i) => [Number(`${ideaId}${i + 1}`), delta]);
219
+ const payFor = (ideaId) =>
220
+ tasksOf(ideaId).map(([taskId, delta]) => ({ reason: `idea.credit.task_ship:${ideaId}:${taskId}`, task_id: taskId, delta }));
221
+
222
+ // 601 — the thought whose work the project tore out. 602 — its twin, untouched.
223
+ // 603 — the thought that never caused anything at all.
224
+ const DEAD = { id: 601, title: 'It caused a version, then the project removed it', status: 'promoted' };
225
+ const LIVE = { id: 602, title: 'It caused the same work, and the work is still there', status: 'promoted' };
226
+ const NEVER = { id: 603, title: 'It never caused anything', status: 'discarded' };
227
+
228
+ function reset() {
229
+ creditLog = [...payFor(601), ...payFor(602)];
230
+ removals = new Map();
231
+ }
232
+ const kill = (taskId) => removals.set(String(taskId), { proposalTaskId: 990, paths: ['modules/gone/'] });
233
+ // Everything idea 601 ever caused, torn out. 602 is never touched.
234
+ const killAll = () => { for (const [taskId] of tasksOf(601)) kill(taskId); };
235
+
236
+ // ═══ THE CRITERION'S OWN SCENARIO ══════════════════════════════════════════
237
+
238
+ await test('THE SCENARIO: a large dead thought draws at FULL SIZE and ZERO brightness', async () => {
239
+ reset();
240
+ killAll();
241
+ const r = await render([DEAD, LIVE, NEVER]);
242
+ const dead = r.glyph(601);
243
+
244
+ assert.equal(dead.object, 'remnant', 'the ledger alone makes it a remnant — nothing set that');
245
+ assert.equal(dead.lit, 0, 'zero brightness: none of the work it caused is still alive');
246
+ // FULL SIZE, measured rather than promised: the diameter is computed from a
247
+ // `scale` the real size read produced AFTER the removal was confirmed.
248
+ const expected = 9 + (32 - 9) * compressIdeaSize(TOTAL);
249
+ assert.ok(Math.abs(dead.d - Math.round(expected)) <= 1,
250
+ `it keeps the diameter its 240 credits earn (${dead.d}px vs ~${expected.toFixed(1)}px)`);
251
+ });
252
+
253
+ await test('SIZE NEVER FELL — the dead thought is exactly as big as its untouched twin', async () => {
254
+ reset();
255
+ killAll();
256
+ const r = await render([DEAD, LIVE, NEVER]);
257
+ const dead = r.glyph(601);
258
+ const live = r.glyph(602);
259
+
260
+ // The two were paid identically; only one had its work removed. This is the
261
+ // claim hall_sky.mjs cannot make, because there the sizes were written by hand.
262
+ assert.equal(dead.d, live.d, 'removal took the light and left the size alone');
263
+ assert.equal(r.rows[0].size.credits, TOTAL, 'and the credits behind it are untouched too');
264
+ assert.equal(r.rows[0].size.credits, r.rows[1].size.credits);
265
+ assert.equal(live.lit, 1, 'while the twin is still fully lit');
266
+ });
267
+
268
+ await test('THE DISTINCTION: dead and never-caused do not render alike on EITHER channel', async () => {
269
+ reset();
270
+ killAll();
271
+ const r = await render([DEAD, LIVE, NEVER]);
272
+ const dead = r.glyph(601);
273
+ const never = r.glyph(603);
274
+
275
+ // Channel one — size. The whole reason there are two.
276
+ assert.ok(dead.d > never.d,
277
+ `the thought that mattered is visibly bigger (${dead.d}px vs ${never.d}px)`);
278
+ assert.equal(never.d, 9, 'a thought that caused nothing sits at the floor diameter');
279
+ // Channel two — brightness, and it runs the OPPOSITE way, which is the part a
280
+ // single number could never express: the dead one is dark, the never-caused
281
+ // one is LIT, because nothing has been taken from it.
282
+ assert.equal(dead.lit, 0);
283
+ assert.equal(never.lit, 1);
284
+ assert.notEqual(dead.object, never.object);
285
+ });
286
+
287
+ await test('and the distinction survives in WORDS, for a reader who cannot see the picture', async () => {
288
+ reset();
289
+ killAll();
290
+ const r = await render([DEAD, LIVE, NEVER]);
291
+ assert.match(r.label(601), /burned out/);
292
+ assert.match(r.label(601), /none of it still alive/);
293
+ assert.match(r.label(601), new RegExp(`size ${TOTAL}`), 'the size is spoken, not only drawn');
294
+ assert.match(r.label(603), /declined/);
295
+ assert.doesNotMatch(r.label(603), /still alive/,
296
+ 'a thought that caused nothing says nothing about what survived — there is nothing to survive');
297
+ });
298
+
299
+ // ═══ the edges that make the two channels mean what they say ════════════════
300
+
301
+ await test('ZERO OVER ZERO IS LIT, NOT DARK — a fresh thought is not a burned-out one', async () => {
302
+ reset();
303
+ killAll();
304
+ const fresh = { id: 604, title: 'Filed this morning', status: 'open' };
305
+ const r = await render([DEAD, fresh]);
306
+ const a = r.glyph(601);
307
+ const b = r.glyph(604);
308
+
309
+ assert.equal(b.object, 'protostar', 'nothing removed from it, because nothing came of it yet');
310
+ assert.equal(b.lit, 1, 'it is lit — returning 0 would merge it with the remnant');
311
+ assert.equal(a.lit, 0);
312
+ assert.ok(a.d > b.d, 'and the sizes part them too');
313
+
314
+ // THE SERVER'S OWN ANSWER, not only the drawing. This assertion exists
315
+ // because the case above it did NOT red when idea-brightness.js was made to
316
+ // return 0 for zero-over-zero: the renderer's `litOf` ignores the share
317
+ // entirely when `hasWork` is false, so the glyph stayed lit for a reason that
318
+ // had nothing to do with the rule this case is named after. A proof that
319
+ // names a failure mode and rides a different guard past it is the exact
320
+ // mistake docs/governance/bongos-v2-ideator-fork-proof.md records.
321
+ const freshState = r.rows[1].state;
322
+ assert.equal(freshState.hasWork, false, 'the server says there is no work');
323
+ assert.equal(freshState.brightness, 1,
324
+ 'and it says the share is ONE — zero over zero is lit, because nothing has been taken away');
325
+ });
326
+
327
+ await test('brightness is weighted by WORTH, not by count — losing the big piece dims most', async () => {
328
+ reset();
329
+ // One of four tasks removed. By count that is 25%; by worth, 120 of 240.
330
+ kill(tasksOf(601)[0][0]); // the 120-credit hop of four
331
+ const r = await render([DEAD]);
332
+ const g = r.glyph(601);
333
+
334
+ assert.equal(g.object, 'star', 'partly dimmed is still burning — it goes out only when nothing survives');
335
+ assert.ok(Math.abs(g.lit - 0.5) < 0.001,
336
+ `the share is the payout that died, not the fraction of tasks (${g.lit})`);
337
+ assert.notEqual(g.lit, 0.75, 'which is what counting tasks would have given');
338
+ assert.equal(r.rows[0].size.credits, TOTAL, 'and the size still has not moved');
339
+ });
340
+
341
+ await test('a dimming thought stays the SAME SIZE the whole way down', async () => {
342
+ const widths = [];
343
+ const hops = tasksOf(601);
344
+ // nothing removed, then the smallest, then the two smallest, then all four
345
+ for (const doomed of [[], hops.slice(3), hops.slice(2), hops]) {
346
+ reset();
347
+ for (const [taskId] of doomed) kill(taskId);
348
+ const r = await render([DEAD]);
349
+ widths.push([r.glyph(601).d, r.glyph(601).lit]);
350
+ }
351
+ const diameters = widths.map((w) => w[0]);
352
+ assert.equal(new Set(diameters).size, 1, `one diameter the whole way down, got ${diameters.join(', ')}`);
353
+ const lits = widths.map((w) => w[1]);
354
+ assert.deepEqual(lits, [...lits].sort((a, b) => b - a), 'while the light falls monotonically');
355
+ assert.equal(lits[0], 1, 'from fully lit');
356
+ assert.equal(lits[lits.length - 1], 0, 'to fully out');
357
+ });
358
+
359
+ await test('AN UNREADABLE REMOVAL IS NOT A DEATH — it stays lit and full, and is never a remnant', async () => {
360
+ reset();
361
+ killAll();
362
+ // The port is gone: "we could not look" — a third answer, and the one most
363
+ // easily collapsed into "it died". Collapsing it renders a healthy instance
364
+ // as a field of hollow rings the moment the lifecycle module fails to mount.
365
+ // THE PORT IS PRESENT AND CANNOT ANSWER, which is the branch the real read
366
+ // actually takes: it tests `typeof port.confirmedRemovalsForTasks ===
367
+ // 'function'`. Swapping the RESOLVER instead does not work here and the
368
+ // reason is worth recording — idea-brightness.js destructures
369
+ // `const { resolveOptional } = api` at LOAD time, so a later property swap
370
+ // never reaches it (trail-removal.js avoids that on purpose and says so in
371
+ // its header; this module does not). Removing the method reaches the same
372
+ // branch through the door the code really uses.
373
+ const savedFn = removalsPort.confirmedRemovalsForTasks;
374
+ delete removalsPort.confirmedRemovalsForTasks;
375
+ try {
376
+ const r = await render([DEAD]);
377
+ const g = r.glyph(601);
378
+ assert.notEqual(g.object, 'remnant', 'an unread removal must never be drawn as a confirmed one');
379
+ assert.equal(g.lit, 1, 'the glyph stays FULL — the doubt goes in words, not in the picture');
380
+ assert.match(r.label(601), /unknown/, 'and the words say the share is unknown');
381
+ assert.ok(g.d > 9, 'the size, which never depended on the removal facts, is still drawn');
382
+ } finally {
383
+ removalsPort.confirmedRemovalsForTasks = savedFn;
384
+ }
385
+ });
386
+
387
+ await test('the object comes from the LEDGER, not from the status the row carries', async () => {
388
+ reset();
389
+ killAll();
390
+ // Same status as the live twin — `promoted`. Only the ledger differs, and the
391
+ // ledger is what decides. A resolver reading status would call both stars.
392
+ const r = await render([DEAD, LIVE]);
393
+ assert.equal(r.rows[0].status, r.rows[1].status, 'precondition: the two statuses agree');
394
+ assert.equal(r.glyph(601).object, 'remnant');
395
+ assert.equal(r.glyph(602).object, 'star');
396
+ });
397
+
398
+ console.log('');
399
+ summary();