@bongos/core 1.21.8 → 1.21.10

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.
@@ -122,6 +122,10 @@ const governmentPort = {
122
122
  // `charter` block. Opening a sitting decides nothing; the vote does.
123
123
  proposeCharter: board.proposeCharter,
124
124
  genesisStageStatus: board.genesisStageStatus,
125
+ // task 1004529 — who decides what. The table, resolved, and the check a
126
+ // building's own direct route runs before letting one builder act alone.
127
+ decisionRulesView: board.decisionRulesView,
128
+ checkActAlone: board.checkActAlone,
125
129
  };
126
130
 
127
131
  function registerSeams() {
@@ -225,6 +229,31 @@ module.exports = function buildGovernmentRouter() {
225
229
  }
226
230
  return rank;
227
231
  }
232
+ // WHO DECIDES AT PEOPLE (task 1004529). Giving or taking a custom rank is
233
+ // People's own act, and the project's decision-rules table says who may do it
234
+ // alone. As today (the default row) the route's own permission is the whole
235
+ // gate, unchanged. Once a passed amendment hands People to a sitting, one
236
+ // builder may no longer do it alone — the route refuses and names the matter
237
+ // route instead — unless the row's rule lets a seated member decide alone and
238
+ // this builder is seated. Server-side, per request, uncached (ADR 0016).
239
+ // Fail-CLOSED: an unreadable row refuses rather than waving the act through.
240
+ async function peopleMayActAlone(req, res) {
241
+ let out;
242
+ try {
243
+ out = await board.checkActAlone('people', req.builder.id);
244
+ } catch (err) {
245
+ log.error('[gds] people decision rule unreadable', err);
246
+ res.fail('decision_rule_unreadable', { status: 503, message: 'who decides at People could not be read just now' });
247
+ return false;
248
+ }
249
+ if (out.ok) return true;
250
+ res.fail('decided_by_sitting', 409, {
251
+ area: out.area, rule: out.rule, who: out.who,
252
+ what_this_means: out.what_this_means, open_with: out.open_with,
253
+ });
254
+ return false;
255
+ }
256
+
228
257
  const ARCHON = 'archon';
229
258
  const ARCHON_REASON = 'the Archon rank holds every permission and is immutable — it is the rank that can never be locked out of this tab (ADR 0172)';
230
259
  const ASSIGN_REASON = 'a seeded rank is assigned by rank (R95b sync); change the builder\'s rank instead';
@@ -447,6 +476,7 @@ module.exports = function buildGovernmentRouter() {
447
476
  try {
448
477
  const rank = await loadAssignableRank(res, rankKey);
449
478
  if (rank === null) return;
479
+ if (!(await peopleMayActAlone(req, res))) return;
450
480
  const assigned = await db.assignRank({
451
481
  builderId: req.body.builder_id,
452
482
  rankKey,
@@ -472,6 +502,7 @@ module.exports = function buildGovernmentRouter() {
472
502
  try {
473
503
  const rank = await loadAssignableRank(res, rankKey);
474
504
  if (rank === null) return;
505
+ if (!(await peopleMayActAlone(req, res))) return;
475
506
  const removed = await db.unassignRank({ builderId, rankKey });
476
507
  res.json({ ok: true, builder_id: builderId, rank_key: rankKey, removed });
477
508
  } catch (err) {
@@ -678,6 +709,90 @@ module.exports = function buildGovernmentRouter() {
678
709
  }
679
710
  });
680
711
 
712
+ // rank: perm board.item.open — propose a change to WHO DECIDES WHAT (task
713
+ // 1004529): one or more buildings' rows of the decision-rules table, filed as
714
+ // an amendment of kind 'decision_rules' and sat under the constitution IN
715
+ // FORCE, like every amendment. Nothing here changes the table — only the
716
+ // sitting passing does, inside its closing transaction. The same atom as
717
+ // proposing any amendment.
718
+ //
719
+ // Body: { rules: [{ area, decided_by, rule?, who? }], rationale_md? }
720
+ // area ∈ rules disputes people planning automation review release treasury
721
+ // (the charter is the board — change it with a constitution amendment)
722
+ // decided_by 'gate' (as today) | 'nobody' (vacant) | rank:<key> | rank:<key>+ | rank:a,b
723
+ // rule decide | first | consent | majority | unanimous | signoff (vacant with 'nobody';
724
+ // none with 'gate')
725
+ // who the seat in words, display only ("Mira under $50, the council above")
726
+ router.post('/government/board/decision-rules', api.requireBuilder, api.requirePermission('board.item.open'), async (req, res) => {
727
+ if (validateOrRespond(req, res, {
728
+ rules: { required: true, type: 'array' },
729
+ rationale_md: { type: 'string', maxLength: 5000 },
730
+ })) return;
731
+ try {
732
+ const out = await board.proposeDecisionRules({
733
+ rules: req.body.rules,
734
+ rationaleMd: req.body.rationale_md ?? null,
735
+ proposedBy: req.builder.id,
736
+ });
737
+ if (!out.ok) {
738
+ if (out.reason === 'no_change') return res.fail('no_change', 409, { reason: 'every building named is already decided this way' });
739
+ return res.fail('invalid_decision_rules', 400, {
740
+ reason: out.reason,
741
+ ...(out.area ? { area: out.area } : {}),
742
+ ...(out.field ? { field: out.field } : {}),
743
+ ...(out.expected ? { expected: out.expected } : {}),
744
+ ...(out.rank_key ? { rank_key: out.rank_key } : {}),
745
+ });
746
+ }
747
+ res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, rules: out.rules });
748
+ } catch (err) {
749
+ log.error('[gds] POST /government/board/decision-rules', err);
750
+ res.fail('propose_failed', { status: 500, message: 'internal error' });
751
+ }
752
+ });
753
+
754
+ // rank: perm board.item.open — put a MATTER to a building's sitting (task
755
+ // 1004529): the board's new subjects, one per building other than the
756
+ // charter. Refused unless the building's row names a sitting — under the
757
+ // default rows every building is decided as today and this route opens
758
+ // nothing. The sitting is snapshotted from the row (who and how).
759
+ //
760
+ // Body: { area, title, detail_md?, action? }
761
+ // action (People only): { type: 'assign_rank' | 'unassign_rank', builder_id, rank_key }
762
+ // — carried out when the sitting passes, under the same walls as the direct route.
763
+ router.post('/government/board/matters', api.requireBuilder, api.requirePermission('board.item.open'), async (req, res) => {
764
+ if (validateOrRespond(req, res, {
765
+ area: { required: true, type: 'string', maxLength: 32 },
766
+ title: { required: true, type: 'string', maxLength: 200 },
767
+ detail_md: { type: 'string', maxLength: 5000 },
768
+ action: { type: 'object' },
769
+ })) return;
770
+ try {
771
+ const out = await board.openMatter({
772
+ area: req.body.area,
773
+ title: req.body.title,
774
+ detailMd: req.body.detail_md ?? null,
775
+ action: req.body.action ?? null,
776
+ proposedBy: req.builder.id,
777
+ });
778
+ if (!out.ok) {
779
+ if (out.reason === 'decided_by_gate' || out.reason === 'no_one_holds_this') {
780
+ return res.fail(out.reason, 409, { area: out.area, ...(out.gate ? { gate: out.gate } : {}), what_this_means: out.what_this_means });
781
+ }
782
+ return res.fail('invalid_matter', 400, {
783
+ reason: out.reason,
784
+ ...(out.field ? { field: out.field } : {}),
785
+ ...(out.expected ? { expected: out.expected } : {}),
786
+ ...(out.rank_key ? { rank_key: out.rank_key } : {}),
787
+ });
788
+ }
789
+ res.status(201).json({ ok: true, matter: out.matter, item: out.item, decided: out.decided });
790
+ } catch (err) {
791
+ log.error('[gds] POST /government/board/matters', err);
792
+ res.fail('matter_failed', { status: 500, message: 'internal error' });
793
+ }
794
+ });
795
+
681
796
  // rank: perm board.vote.cast — cast a vote on an open board item (R08 of goal
682
797
  // 1000069, ADR 0175). The atom FLOORS AT XENOS deliberately (the R04 carry-
683
798
  // note): the permission is only the coarse gate, and the real wall is the
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.8",
3
+ "version": "1.21.10",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.8",
9
+ "version": "1.21.10",
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.21.8",
3
+ "version": "1.21.10",
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",
@@ -8804,5 +8804,17 @@
8804
8804
  "id": "1004441",
8805
8805
  "text": "Checked and signed off on the live site: artists and ideators now read as their own craft on profiles, the builders list and the status page. Their earnings charts show, and builders can choose their main role in Settings."
8806
8806
  }
8807
+ ],
8808
+ "1.21.9": [
8809
+ {
8810
+ "id": "1004529",
8811
+ "text": "Each project can now set who decides what in each part of its government: the charter, rules, disputes, people, planning, automation, review, release and treasury. For each part it names who decides and how: one person decides"
8812
+ }
8813
+ ],
8814
+ "1.21.10": [
8815
+ {
8816
+ "id": "1004545",
8817
+ "text": "The list of every piece of on-screen text now comes out the same on a Windows computer as on the build server, so updating it on Windows no longer breaks the automatic checks. Notes the authors left inside page source are no l"
8818
+ }
8807
8819
  ]
8808
8820
  }
@@ -119,8 +119,12 @@ function lineAt(starts, index) {
119
119
 
120
120
  // Does this look like prose a person reads, rather than code that happens to be a
121
121
  // string? Conservative on purpose — a false positive costs an artist's attention.
122
+ //
123
+ // Measured with CRLF folded to LF: the length cap must give a multi-line run the
124
+ // same verdict on a Windows checkout as on CI's, or the committed registry differs
125
+ // by platform (task 1004545 — a 396-char run was 403 under CRLF and vanished).
122
126
  function looksLikeCopy(raw) {
123
- const s = String(raw).trim();
127
+ const s = String(raw).replace(/\r\n/g, '\n').trim();
124
128
  if (s.length < 4 || s.length > 400) return false;
125
129
  if (!/[A-Za-z]{2}/.test(s)) return false; // needs actual letters
126
130
  if (!/\s/.test(s) && !/[.!?]$/.test(s)) return false; // one bare word is a token
@@ -155,12 +159,25 @@ function trimmedSpan(group, groupStart) {
155
159
  return { start: groupStart + lead, end: groupStart + group.length - trail };
156
160
  }
157
161
 
158
- // Strip <script>/<style> bodies so their contents are not read as page text.
159
- // Replaced with spaces of equal length so every line number stays truthful.
162
+ // Strip HTML comments and <script>/<style> bodies so their contents are not read
163
+ // as page text. Replaced with spaces of equal length so every line number — and
164
+ // every span copy-apply.js writes through — stays truthful.
165
+ //
166
+ // Comments are author notes, never something a visitor reads (task 1004545: a
167
+ // header comment's literal `<a>` opened a fake text node). ONE alternation, not
168
+ // three passes, so whichever construct opens first wins, as in a browser: a
169
+ // `<style>` named inside a comment cannot swallow the page up to the real
170
+ // `</style>`, and a `<!--` inside a script string cannot eat past `</script>`.
171
+ //
172
+ // A comment keeps its `<` and `>` and blanks only its inside: marker comments
173
+ // (`<!--apex-only:start-->`, `<!--brand-if:…-->`) split a sentence into segments
174
+ // the server shows or drops independently, so each segment must stay its own row
175
+ // with its own stable id — blanking the whole comment would fuse them.
160
176
  function stripEmbedded(html) {
161
- return html
162
- .replace(/<script\b[\s\S]*?<\/script>/gi, (m) => m.replace(/[^\n]/g, ' '))
163
- .replace(/<style\b[\s\S]*?<\/style>/gi, (m) => m.replace(/[^\n]/g, ' '));
177
+ return html.replace(/<!--[\s\S]*?-->|<script\b[\s\S]*?<\/script>|<style\b[\s\S]*?<\/style>/gi, (m) => {
178
+ const blank = m.replace(/[^\r\n]/g, ' ');
179
+ return m.startsWith('<!--') ? `<${blank.slice(1, -1)}>` : blank;
180
+ });
164
181
  }
165
182
 
166
183
  function fromHtml({ source, rel }) {
@@ -193,7 +210,7 @@ function fromHtml({ source, rel }) {
193
210
 
194
211
  // 3. <title> is the page's own name — always copy.
195
212
  const titleRe = /<title>([^<]{2,200})<\/title>/gi;
196
- while ((m = titleRe.exec(source)) !== null) {
213
+ while ((m = titleRe.exec(cleaned)) !== null) {
197
214
  const titleStart = m.index + '<title>'.length;
198
215
  out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: 'html-title', confidence: 'certain', ...trimmedSpan(m[1], titleStart) });
199
216
  }
@@ -281,6 +281,12 @@ const GOVERNMENT_PINS = {
281
281
  // task 1004523: the governor's docket — every matter awaiting a governor. The
282
282
  // coarse gate is the room's; each source inside is scoped to its own atom.
283
283
  'GET /government/docket': 'perm:board.vote.cast',
284
+ // task 1004529: who decides what — a change to the decision-rules table is an
285
+ // amendment by another door (same atom as proposing one), and a building's
286
+ // matter is put to its sitting by the same atom. Only a passed amendment
287
+ // writes the table; neither route does.
288
+ 'POST /government/board/decision-rules': 'perm:board.item.open',
289
+ 'POST /government/board/matters': 'perm:board.item.open',
284
290
  };
285
291
 
286
292
  // Module-scoped privileged-rank pins: a privileged route that lives in a FEATURE
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.8'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.10'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -98,6 +98,68 @@ test('stripEmbedded keeps line numbers truthful', () => {
98
98
  assert.equal(copy.line, 6, 'a stripped script must not shift the lines after it — a wrong line number sends an artist to the wrong place');
99
99
  });
100
100
 
101
+ // Task 1004545. street.html's header comment held a literal `<a>`, which opened a
102
+ // fake text node; under CRLF that run was 403 characters, under LF 396 — so a
103
+ // Windows regen dropped a row CI kept and redded "the committed artifacts match".
104
+ // The fixture rebuilds both halves: a tag inside a comment, and a run sized to
105
+ // straddle the cap.
106
+ // Eight 45-char lines under a 28-char head: 396 chars on LF, 404 on CRLF.
107
+ const NEAR_CAP_LINES = Array.from({ length: 8 }, () => ` ${'word '.repeat(8)}s.`);
108
+ const EOL_FIXTURE = [
109
+ '<!doctype html>',
110
+ '<!-- Author note: a building is a door (an <a>) to its interior.',
111
+ ' The template is street.css, the <style> on the tokens.',
112
+ '-->',
113
+ '<style>.x { color: red; }</style>',
114
+ '<p>A real sentence on the page,',
115
+ ...NEAR_CAP_LINES.slice(0, -1),
116
+ `${NEAR_CAP_LINES.at(-1)}</p>`,
117
+ '<p title="A tooltip a person reads">Second real line.</p>',
118
+ ].join('\n');
119
+
120
+ test('LF and CRLF checkouts extract identical rows (task 1004545)', () => {
121
+ const lf = inv.fromHtml({ source: EOL_FIXTURE, rel: 'p.html' });
122
+ const crlf = inv.fromHtml({ source: EOL_FIXTURE.replace(/\n/g, '\r\n'), rel: 'p.html' });
123
+ const strip = (rows) => rows.map(({ text, line, origin }) => ({ text, line, origin }));
124
+ assert.deepEqual(strip(crlf), strip(lf), 'the registry must not depend on the checkout\'s line endings');
125
+ const run = EOL_FIXTURE.slice(EOL_FIXTURE.indexOf('A real'), EOL_FIXTURE.indexOf('</p>'));
126
+ assert.ok(run.length <= 400 && run.replace(/\n/g, '\r\n').length > 400, 'the fixture must straddle the cap, or this test proves nothing');
127
+ // The multi-line sentence sits under the cap only when CRLF counts as one
128
+ // character — so this is the case that reds if the length rule regresses.
129
+ assert.ok(lf.some((r) => r.text.startsWith('A real sentence on the page')), 'a multi-line run under the cap is copy on LF');
130
+ assert.ok(crlf.some((r) => r.text.startsWith('A real sentence on the page')), 'and the same run is copy on CRLF');
131
+ });
132
+
133
+ test('HTML comments are never copy, and a tag named inside one swallows nothing', () => {
134
+ const rows = inv.fromHtml({ source: EOL_FIXTURE, rel: 'p.html' });
135
+ const texts = rows.map((r) => r.text);
136
+ assert.ok(!texts.some((t) => /Author note|its interior|the tokens/.test(t)), 'comment text is an author note, not page copy');
137
+ // The comment names `<style>`: a strip pass that ran style-first would blank from
138
+ // there to the real </style> — harmless here, but the same shape with <script>
139
+ // ahead of real copy would erase it. Both real lines must survive.
140
+ assert.ok(texts.includes('Second real line.'));
141
+ assert.ok(texts.includes('A tooltip a person reads'));
142
+ const real = rows.find((r) => r.text === 'Second real line.');
143
+ assert.equal(real.line, 15, 'a stripped comment must not shift the lines after it');
144
+ // A span still addresses the file's own characters (copy-apply writes through it).
145
+ assert.equal(EOL_FIXTURE.slice(real.start, real.end), 'Second real line.');
146
+ });
147
+
148
+ test('a marker comment still splits copy into the segments the server drops independently', () => {
149
+ // The landing footer: the first sentence is apex-only, the second is not. One
150
+ // fused row would give both a new id and hide that a child instance drops half.
151
+ const src = '<p><!--apex-only:start-->A non-profit, capture-resistant commons. <!--apex-only:end-->Built by agents, on the platform.</p>';
152
+ const texts = inv.fromHtml({ source: src, rel: 'p.html' }).map((r) => r.text);
153
+ assert.ok(texts.includes('A non-profit, capture-resistant commons.'));
154
+ assert.ok(texts.includes('Built by agents, on the platform.'));
155
+ });
156
+
157
+ test('a comment opener inside a script string does not eat the page after it', () => {
158
+ const src = '<script>const s = "<!--";</script>\n<p>Copy after the script.</p>\n<!-- x -->';
159
+ const texts = inv.fromHtml({ source: src, rel: 'p.html' }).map((r) => r.text);
160
+ assert.ok(texts.includes('Copy after the script.'));
161
+ });
162
+
101
163
  test('the registry is deterministic and internally consistent', () => {
102
164
  const a = inv.buildRegistry();
103
165
  const b = inv.buildRegistry();
@@ -120,6 +120,9 @@ const stub = {
120
120
  unassignRank: async () => true,
121
121
  listRanksWithGrants: async () => [],
122
122
  listAssignments: async () => [],
123
+ // task 1004529: People's row is read before a rank is given or taken. No rows
124
+ // = every building as today, so this matrix's walls are the route's own.
125
+ listDecisionRules: async () => [],
123
126
  };
124
127
  const REAL = {};
125
128
  for (const k of Object.keys(stub)) {