@bongos/core 1.24.74 → 1.24.76
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 +77 -42
- package/.claude/skills/blocker-review/SKILL.md +10 -0
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +6 -2
- package/docs/api-reference.md +2 -2
- package/docs/copy-inventory.md +55 -47
- package/docs/copy-registry.json +143 -71
- package/docs/module-api-changelog.md +2 -0
- package/docs/packs/engineer.md +2 -2
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +10 -10
- package/modules/government/board.js +10 -4
- package/modules/government/db.js +7 -5
- package/modules/government/genesis-close.js +45 -0
- package/modules/government/migrations/government_038_vote_cast_via.sql +23 -0
- package/modules/government/routes/government.js +4 -5
- package/modules/hall-ui/public/board-room.js +1 -0
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/deploy.js +56 -5
- package/modules/hall-ui/public/genesis-home.js +7 -12
- package/modules/hall-ui/public/home-city.js +7 -2
- package/modules/hall-ui/public/home-idea-box.css +33 -0
- package/modules/hall-ui/public/home-idea-box.js +106 -0
- package/modules/hall-ui/public/index.html +2 -0
- package/modules/hall-ui/public/project-settings.css +15 -0
- package/modules/hall-ui/records/home-city.md +21 -0
- package/modules/ideas/routes/blockers.js +47 -1
- package/modules/onboarding/genesis-home.js +65 -8
- package/modules/onboarding/routes/onboarding.js +15 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/scripts/bongos/blocker-recheck.js +111 -0
- package/scripts/bongos/government-walk.js +4 -4
- package/scripts/gds/blocker-recheck.js +7 -0
- package/scripts/hall-preview/server.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/deploy_page_projects.mjs +49 -0
- package/tests/genesis_home.mjs +86 -1
- package/tests/government_agent_asks.mjs +92 -2
- package/tests/government_board_genesis_stage.mjs +83 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/home_idea_box.mjs +178 -0
|
@@ -175,6 +175,27 @@ leaderboard.
|
|
|
175
175
|
- Pinned in `tests/home_studio.mjs` and `tests/home_studio_widget.mjs` (real browser, empty states included); harness
|
|
176
176
|
fixtures `copy-desk__studio.json` and `home__studio__works.json`.
|
|
177
177
|
|
|
178
|
+
## The idea box — the ideator's Home opens on it (task 1005078)
|
|
179
|
+
|
|
180
|
+
The owner's work-first hall (2026-11-04: "the hall is for doing work, not looking at stuff"), goal 1000095. In
|
|
181
|
+
Ideating, the first thing on Home is a place to type an idea and file it; the sky and the ladder stay under it.
|
|
182
|
+
|
|
183
|
+
- **One section, still.** `home-idea-box.js` is not a hall widget (Home's stats zone holds one): `home-city.js`
|
|
184
|
+
attaches it at the top of its section, the gallery's way, and `craftHome()` shows it in Ideating only. It shows
|
|
185
|
+
even when `GET /home/city` failed, because filing needs no picture of the other ideas.
|
|
186
|
+
- **One box and a button** (owner, 2026-11-05: "the idea submission should only have one box"). Enter files and
|
|
187
|
+
Shift+Enter starts a new line (owner, same day); the box grows with what is typed. The first non-empty line is
|
|
188
|
+
the title and any lines after it are `body_md`. No kind or goal picker: it files `kind: 'feature'` through
|
|
189
|
+
`POST /inbox`, the sky composer's quick filing, and triage routes it. The top bar's + idea button is unchanged.
|
|
190
|
+
- **What it says.** A refusal keeps the words and names the reason (`details.reason`, else the code); a filing
|
|
191
|
+
clears the box and links to Your thinking. It reads nothing.
|
|
192
|
+
- **The setup guide hides in Ideating** (`#welcome-scroll`, `home-idea-box.css`): it is about building on your
|
|
193
|
+
own machine and pushed the box below the fold. Every other craft still shows it.
|
|
194
|
+
- **Preview.** `scripts/hall-preview/server.js` answers `POST /inbox` with the canned `inbox.post.json`, so the
|
|
195
|
+
filed state can be seen. Pinned by `tests/home_idea_box.mjs` (8 cases, a real browser).
|
|
196
|
+
- **Sign-off.** The owner saw it in the preview at 1440 dark and said "Looks right, ship it" (2026-11-05), then
|
|
197
|
+
asked for Enter / Shift+Enter, which was added before the ship.
|
|
198
|
+
|
|
178
199
|
## The governor's Home is the street (task 1005064)
|
|
179
200
|
|
|
180
201
|
The owner, 2026-11-04, after the four Homes went live: "when you tab between the pages the home screen
|
|
@@ -79,6 +79,28 @@ function askRequired(req, body) {
|
|
|
79
79
|
return !TEMPLATED_SOURCES.includes(body.source);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// CHECKED, NOT INFERRED (task 1005120). A blocker spends the owner's attention, the
|
|
83
|
+
// scarcest thing a project has. One project's blocker asked its owner to "apply to
|
|
84
|
+
// Plaid for OAuth" for days after the project's own shipped work found there was
|
|
85
|
+
// nothing to apply for: it had been filed on an inference (one bank worked, one did
|
|
86
|
+
// not) when the page that settled it was one click away. So the same filings that
|
|
87
|
+
// owe an ask (an AI session, by hand) also owe `checked` — one sentence naming what
|
|
88
|
+
// the session opened or ran that showed only a person can do this. It is written into
|
|
89
|
+
// the blocker's body, so whoever re-reads the blocker later (the daily review re-checks
|
|
90
|
+
// before asking again) can see what the claim rested on and test it against what has
|
|
91
|
+
// shipped since. The floor is a length, because a length is all a server can judge;
|
|
92
|
+
// it stops "probably needed", which is the shape the hunch takes.
|
|
93
|
+
const CHECKED_MIN = 30;
|
|
94
|
+
const CHECKED_REQUIRED_MESSAGE = 'An AI session files a blocker only after checking it really needs a person. '
|
|
95
|
+
+ `Add "checked": one sentence (${CHECKED_MIN} characters or more) saying what you opened or ran that showed it, `
|
|
96
|
+
+ 'for example the settings page you read or the command whose output you saw. If you have not checked, check first.';
|
|
97
|
+
// The line names WHO checked, so a blocker is tracked back to its filer — the person a
|
|
98
|
+
// re-check asks first — without a new column on the core-owned table.
|
|
99
|
+
function checkedLine(checked, builder) {
|
|
100
|
+
const who = builder && builder.github_login ? ` (by @${builder.github_login})` : '';
|
|
101
|
+
return `**Checked before filing${who}:** ${String(checked).trim()}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
82
104
|
// The `ideas.fileBlocker` port's writer: POST /blockers' shape and length rules, for a caller
|
|
83
105
|
// inside the server, which has no request to validate. A refusal throws with code BAD_BLOCKER.
|
|
84
106
|
function fileBlockerFromServer({ title, bodyMd = '', source, sourceRef } = {}) {
|
|
@@ -145,6 +167,8 @@ module.exports = function buildBlockersRouter() {
|
|
|
145
167
|
// person answers (task 1004703, see THE ASK above); a slop ask files nothing
|
|
146
168
|
// (400 `ask_refused`). An AI session filing by hand (a CLI session, any `source`
|
|
147
169
|
// but a templated script's) must send one: without it, 400 `ask_required` (task 1004704).
|
|
170
|
+
// The same filing also sends `checked` — what it opened or ran that showed a person
|
|
171
|
+
// is needed — or 400 `checked_required`; it is appended to body_md (task 1005120).
|
|
148
172
|
// → 201 { blocker, ask } (ask: the stored card, or null).
|
|
149
173
|
router.post('/blockers', api.requireBuilder, async (req, res) => {
|
|
150
174
|
if (validateOrRespond(req, res, {
|
|
@@ -153,6 +177,7 @@ module.exports = function buildBlockersRouter() {
|
|
|
153
177
|
source: { type: 'string', maxLength: LIMITS.SOURCE },
|
|
154
178
|
source_ref: { type: 'string', maxLength: LIMITS.SOURCE_REF },
|
|
155
179
|
ask: { type: 'object' },
|
|
180
|
+
checked: { type: 'string', maxLength: LIMITS.TITLE },
|
|
156
181
|
})) return;
|
|
157
182
|
const body = req.body || {};
|
|
158
183
|
let verdict = null;
|
|
@@ -177,10 +202,31 @@ module.exports = function buildBlockersRouter() {
|
|
|
177
202
|
return res.fail('ask_refused', { status: 400, message: gov.refusalLine(verdict.errors), details: { errors: verdict.errors } });
|
|
178
203
|
}
|
|
179
204
|
}
|
|
205
|
+
// After the ask, so a filing with no card hears about the card first (task 1005120).
|
|
206
|
+
const checked = typeof body.checked === 'string' ? body.checked.trim() : '';
|
|
207
|
+
if (askRequired(req, body) && checked.length < CHECKED_MIN) {
|
|
208
|
+
return res.fail('checked_required', {
|
|
209
|
+
status: 400,
|
|
210
|
+
message: CHECKED_REQUIRED_MESSAGE,
|
|
211
|
+
details: { field: 'checked', min_length: CHECKED_MIN },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const bodyMd = checked
|
|
215
|
+
? [String(body.body_md ?? '').trim(), checkedLine(checked, req.builder)].filter(Boolean).join('\n\n')
|
|
216
|
+
: (body.body_md ?? '');
|
|
217
|
+
// body_md was validated on its own; the check line is added after, so the whole is
|
|
218
|
+
// held to the same cap rather than quietly growing past it.
|
|
219
|
+
if (bodyMd.length > LIMITS.DESCRIPTION) {
|
|
220
|
+
return res.fail('body_too_long', {
|
|
221
|
+
status: 400,
|
|
222
|
+
message: `The blocker's text and its "checked" line come to ${bodyMd.length} characters; ${LIMITS.DESCRIPTION} at most. Shorten the body.`,
|
|
223
|
+
details: { field: 'body_md', max_length: LIMITS.DESCRIPTION },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
180
226
|
try {
|
|
181
227
|
const blocker = await blockers.createBlocker({
|
|
182
228
|
title: body.title,
|
|
183
|
-
bodyMd
|
|
229
|
+
bodyMd,
|
|
184
230
|
source: body.source ?? 'manual',
|
|
185
231
|
sourceRef: body.source_ref ?? null,
|
|
186
232
|
});
|
|
@@ -326,10 +326,25 @@ async function applyGovernmentChoice(form, builderId, s) {
|
|
|
326
326
|
return { state: 'not_filed', form, reason };
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
// closeStage —
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
329
|
+
// closeStage — CLOSE the current stage, in one call (task 1005121; owner ruling
|
|
330
|
+
// 2026-11-07: "closing a stage shouldn't be such a big thing"). The answer says, by
|
|
331
|
+
// itself, which of two things happened — a caller never needs a second request:
|
|
332
|
+
//
|
|
333
|
+
// { ok: true, closed: true, stage, next_stage, item_id, confirm }
|
|
334
|
+
// The stage is closed and the project has moved on (`next_stage` null at
|
|
335
|
+
// legislation: the alive door, POST /founding/alive, is open instead).
|
|
336
|
+
// `confirm` is 'stage_close' when the founder's own yes carried it — the
|
|
337
|
+
// government port records that HOW on the vote (cast_via).
|
|
338
|
+
// { ok: false, closed: false, reason: 'sitting_open', stage, item_id, already_open,
|
|
339
|
+
// waiting: { pass_rule, needs, voted, waiting_on, closes_at, remaining_seconds },
|
|
340
|
+
// next }
|
|
341
|
+
// A real Board Room sitting is deciding it (more than one person, or a rule
|
|
342
|
+
// one yes cannot carry). Never ok: true — the stage did NOT close. The route
|
|
343
|
+
// answers 202 with this whole body; `next` says what to do, in words.
|
|
344
|
+
//
|
|
345
|
+
// With a government module the port's closeGenesisStage does the deciding (its
|
|
346
|
+
// finished-tasks gate, the founder's confirm where one person decides). With none,
|
|
347
|
+
// the founder's ask closes a finished stage itself. Same rule at every stage.
|
|
333
348
|
async function closeStage(stageKey, builderId, deps = {}) {
|
|
334
349
|
const s = seams(deps);
|
|
335
350
|
const mode = await reconcile(s);
|
|
@@ -337,17 +352,59 @@ async function closeStage(stageKey, builderId, deps = {}) {
|
|
|
337
352
|
if (mode.stage !== stageKey) return { ok: false, reason: 'not_current_stage', stage: mode.stage };
|
|
338
353
|
const all = s.lifecycle && typeof s.lifecycle.genesisTasks === 'function' ? await s.lifecycle.genesisTasks() : {};
|
|
339
354
|
const list = all[stageKey] || [];
|
|
355
|
+
const taskIds = list.map((t) => t.id);
|
|
340
356
|
const gov = s.government;
|
|
357
|
+
const nextOf = (key) => (key === 'legislation' ? null : fm.STAGES[fm.STAGES.indexOf(key) + 1]);
|
|
358
|
+
const closedAnswer = async (itemId, confirm) => {
|
|
359
|
+
const after = await reconcile(s);
|
|
360
|
+
return {
|
|
361
|
+
ok: true, closed: true, stage: stageKey,
|
|
362
|
+
next_stage: stageKey === 'legislation' ? null : (after && after.stage) || nextOf(stageKey),
|
|
363
|
+
item_id: itemId, confirm,
|
|
364
|
+
};
|
|
365
|
+
};
|
|
366
|
+
if (gov && typeof gov.closeGenesisStage === 'function') {
|
|
367
|
+
const r = await gov.closeGenesisStage({ stageKey, taskIds, closedBy: builderId });
|
|
368
|
+
if (r.reason) return { ok: false, reason: r.reason, unfinished: r.unfinished || [] };
|
|
369
|
+
const itemId = r.item ? String(r.item.id) : null;
|
|
370
|
+
if (r.closed) return closedAnswer(itemId, r.cast ? r.cast.cast_via : null);
|
|
371
|
+
return sittingOpen(stageKey, itemId, !r.opened, r.status, builderId);
|
|
372
|
+
}
|
|
341
373
|
if (gov && typeof gov.openGenesisStageClose === 'function') {
|
|
342
|
-
|
|
374
|
+
// An older government module without the one-call close: the sitting opens and
|
|
375
|
+
// the answer says so, rather than claiming a close that did not happen.
|
|
376
|
+
const r = await gov.openGenesisStageClose({ stageKey, taskIds, openedBy: builderId });
|
|
343
377
|
if (!r.opened && r.reason !== 'already_open') return { ok: false, reason: r.reason, unfinished: r.unfinished || [] };
|
|
344
|
-
|
|
378
|
+
const board = await boardByStage(gov);
|
|
379
|
+
return sittingOpen(stageKey, r.item ? String(r.item.id) : null, !r.opened, board[stageKey] || null, builderId);
|
|
345
380
|
}
|
|
346
381
|
if (!list.length || !list.every((t) => FINISHED.includes(t.status))) return { ok: false, reason: 'stage_tasks_unfinished' };
|
|
347
382
|
if (stageKey !== 'legislation') {
|
|
348
|
-
await fm.setFoundingMode({ stage:
|
|
383
|
+
await fm.setFoundingMode({ stage: nextOf(stageKey) }, builderId, modeDeps(s));
|
|
349
384
|
}
|
|
350
|
-
return { ok: true, item_id: null,
|
|
385
|
+
return { ok: true, closed: true, stage: stageKey, next_stage: nextOf(stageKey), item_id: null, confirm: null };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// The "a sitting is deciding it" answer: what happened, what it waits on, what next.
|
|
389
|
+
function sittingOpen(stageKey, itemId, alreadyOpen, status, builderId) {
|
|
390
|
+
const st = status || {};
|
|
391
|
+
const waitingOn = Array.isArray(st.waiting_on) ? st.waiting_on : [];
|
|
392
|
+
const names = waitingOn.map((m) => m.display_name || m.github_login || `builder ${m.builder_id}`);
|
|
393
|
+
const callerWaited = waitingOn.some((m) => String(m.builder_id) === String(builderId));
|
|
394
|
+
let next = `The Board Room is deciding this stage (sitting ${itemId || 'open'}).`;
|
|
395
|
+
if (st.needs) next += ` Its rule: ${st.needs}`;
|
|
396
|
+
if (names.length) next += ` Still to vote: ${names.join(', ')}.`;
|
|
397
|
+
if (callerWaited && itemId) next += ` Your vote is one of them: POST /api/bongos/government/board/items/${itemId}/votes { "direction": "yes" }.`;
|
|
398
|
+
next += ' The stage moves on by itself when the sitting passes; there is nothing else to call.';
|
|
399
|
+
return {
|
|
400
|
+
ok: false, closed: false, reason: 'sitting_open', stage: stageKey, item_id: itemId, already_open: !!alreadyOpen,
|
|
401
|
+
waiting: {
|
|
402
|
+
pass_rule: st.pass_rule || null, needs: st.needs || null,
|
|
403
|
+
voted: Array.isArray(st.voted) ? st.voted : [], waiting_on: waitingOn,
|
|
404
|
+
closes_at: st.closes_at || null, remaining_seconds: st.remaining_seconds ?? null,
|
|
405
|
+
},
|
|
406
|
+
next,
|
|
407
|
+
};
|
|
351
408
|
}
|
|
352
409
|
|
|
353
410
|
// comeAlive — the last door. Only once legislation has passed (or, with no Board Room,
|
|
@@ -170,6 +170,20 @@ function mountGenesisRoutes(router) {
|
|
|
170
170
|
})) return;
|
|
171
171
|
next();
|
|
172
172
|
}, handle((req) => genesis.savePlan(req.body || {}, req.builder.id)));
|
|
173
|
-
|
|
173
|
+
// task 1005121: one call. 200 { closed: true, next_stage } when the stage closed;
|
|
174
|
+
// 202 { ok: false, closed: false, reason: 'sitting_open', waiting, next } when a
|
|
175
|
+
// real sitting is deciding it — never a success for a stage that did not close.
|
|
176
|
+
router.post('/founding/stages/:stage/close', api.requireBuilder, owns, async (req, res) => {
|
|
177
|
+
try {
|
|
178
|
+
const r = await genesis.closeStage(String(req.params.stage), req.builder.id);
|
|
179
|
+
res.set('Cache-Control', 'no-store');
|
|
180
|
+
if (r && r.reason === 'sitting_open') return res.status(202).json(r);
|
|
181
|
+
if (r && r.ok === false) return refuse(res, r);
|
|
182
|
+
res.json(r);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
api.logger('onboarding').error('[onboarding] genesis', err);
|
|
185
|
+
res.status(500).json({ error: { code: 'genesis_failed', message: 'internal error' } });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
174
188
|
router.post('/founding/alive', api.requireBuilder, owns, handle((req) => genesis.comeAlive(req.builder.id)));
|
|
175
189
|
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.76",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.76",
|
|
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.24.
|
|
3
|
+
"version": "1.24.76",
|
|
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/release-notes.json
CHANGED
|
@@ -11469,5 +11469,23 @@
|
|
|
11469
11469
|
"id": "1005142",
|
|
11470
11470
|
"text": "The hall has a game mode: once it is on, every page opens full screen with no sidebar, and Escape brings the menus back when you need them."
|
|
11471
11471
|
}
|
|
11472
|
+
],
|
|
11473
|
+
"1.24.76": [
|
|
11474
|
+
{
|
|
11475
|
+
"id": "1005078",
|
|
11476
|
+
"text": "In Ideating mode, Home now opens on a single box to type and file an idea — press Enter to file it, Shift+Enter for a new line."
|
|
11477
|
+
},
|
|
11478
|
+
{
|
|
11479
|
+
"id": "1005120",
|
|
11480
|
+
"text": "Blockers now cost a real check to file: an AI session must say what it looked at that showed a person is needed, and that note stays on the blocker. The daily blocker review now re-checks each one against what has shipped sinc"
|
|
11481
|
+
},
|
|
11482
|
+
{
|
|
11483
|
+
"id": "1005012",
|
|
11484
|
+
"text": "The Deploy page now says in one plain line whether the project is up to date. When it is, it says merged work in that version is live and that the next version arrives on its own (or waits, if the project is set to stay put)."
|
|
11485
|
+
},
|
|
11486
|
+
{
|
|
11487
|
+
"id": "1005121",
|
|
11488
|
+
"text": "Closing a project-setup stage is now one step: if the founder alone decides, the stage closes and the project moves on at once; if others must vote, the answer says who and what to do next instead of falsely reporting success."
|
|
11489
|
+
}
|
|
11472
11490
|
]
|
|
11473
11491
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/bongos/blocker-recheck.js — what has shipped since a blocker was filed that may
|
|
3
|
+
// already answer it (task 1005120).
|
|
4
|
+
//
|
|
5
|
+
// WHY. A blocker is a claim about the world, made on a date, by a session that has long
|
|
6
|
+
// since ended. One project's blocker asked its owner to "apply to Plaid for Chase and Bank of
|
|
7
|
+
// America OAuth" for days after the project's own shipped work had found there was nothing
|
|
8
|
+
// to apply for — nothing ever re-read it, and it had no linked tasks, so no promotion would
|
|
9
|
+
// ever surface it. /blocker-review runs this before putting a blocker to a person again: it
|
|
10
|
+
// lists the shipped work since the filing that shares the blocker's subject, so the session
|
|
11
|
+
// reads that first and offers to close the blocker when the answer is already in.
|
|
12
|
+
//
|
|
13
|
+
// IT SUGGESTS, IT NEVER DECIDES. A word overlap is a reason to read a task, not proof the
|
|
14
|
+
// blocker is answered; closing one is still a person's verdict in the review. So the match is
|
|
15
|
+
// deliberately simple and explainable (shared subject words, each shown), never a score a
|
|
16
|
+
// reader has to trust.
|
|
17
|
+
//
|
|
18
|
+
// usage: node scripts/bongos/blocker-recheck.js <blocker-id> [--json]
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
// Words that say nothing about a blocker's SUBJECT. Short words are dropped by length.
|
|
23
|
+
const STOP = new Set([
|
|
24
|
+
'about', 'after', 'again', 'already', 'also', 'apply', 'because', 'before', 'being', 'blocker',
|
|
25
|
+
'cannot', 'could', 'does', 'done', 'each', 'from', 'have', 'into', 'just', 'make', 'more', 'must',
|
|
26
|
+
'need', 'needs', 'only', 'other', 'owner', 'should', 'some', 'still', 'than', 'that', 'their',
|
|
27
|
+
'them', 'then', 'there', 'these', 'they', 'this', 'those', 'through', 'until', 'very', 'want',
|
|
28
|
+
'were', 'what', 'when', 'where', 'which', 'while', 'will', 'with', 'without', 'would', 'your',
|
|
29
|
+
'task', 'tasks', 'work', 'page', 'pages', 'project',
|
|
30
|
+
// Words nearly every shipped summary in this ledger carries, so they say nothing about a
|
|
31
|
+
// blocker's subject (measured against a live blocker: these alone matched ten tasks).
|
|
32
|
+
'back', 'both', 'check', 'checks', 'core', 'main', 'once', 'open', 'itself', 'shipped', 'test',
|
|
33
|
+
'tests', 'version', 'versions', 'instance', 'server', 'update', 'copy', 'first', 'every', 'now',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** The subject words of a text: lower-cased, 4+ letters, not a stop word. */
|
|
37
|
+
function subjectWords(text) {
|
|
38
|
+
const out = new Set();
|
|
39
|
+
for (const w of String(text || '').toLowerCase().match(/[a-z][a-z0-9]{3,}/g) || []) {
|
|
40
|
+
if (!STOP.has(w)) out.add(w);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Shipped tasks AFTER the blocker was filed that share at least `minShared` subject words
|
|
47
|
+
* with its title + body, most shared first, then newest. PURE.
|
|
48
|
+
* Each result: { id, short_name, title, shipped_at, value_summary, shared: [words] }.
|
|
49
|
+
*/
|
|
50
|
+
function relatedShipped(blocker, tasks, { minShared = 2, max = 10 } = {}) {
|
|
51
|
+
const filed = Date.parse(blocker && blocker.created_at);
|
|
52
|
+
if (!Number.isFinite(filed)) return [];
|
|
53
|
+
// The TITLE names the subject; a long body mostly carries generic words that match
|
|
54
|
+
// everything. The body is used only when the title alone is too thin to match on.
|
|
55
|
+
let want = subjectWords(blocker.title);
|
|
56
|
+
if (want.size < minShared) want = subjectWords(`${blocker.title || ''} ${blocker.body_md || ''}`);
|
|
57
|
+
const hits = [];
|
|
58
|
+
for (const t of Array.isArray(tasks) ? tasks : []) {
|
|
59
|
+
if (!t || t.status !== 'shipped') continue;
|
|
60
|
+
const at = Date.parse(t.shipped_at);
|
|
61
|
+
if (!Number.isFinite(at) || at <= filed) continue;
|
|
62
|
+
const have = subjectWords(`${t.title || ''} ${t.value_summary || ''}`);
|
|
63
|
+
const shared = [...want].filter((w) => have.has(w)).sort();
|
|
64
|
+
if (shared.length >= minShared) {
|
|
65
|
+
hits.push({ id: t.id, short_name: t.short_name || null, title: t.title, shipped_at: t.shipped_at, value_summary: t.value_summary || null, shared });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
hits.sort((a, b) => (b.shared.length - a.shared.length) || (Date.parse(b.shipped_at) - Date.parse(a.shipped_at)));
|
|
69
|
+
return hits.slice(0, max);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The lines /blocker-review reads. Never "nothing is related" as proof the blocker holds. */
|
|
73
|
+
function reportLines(blocker, hits) {
|
|
74
|
+
const head = `Blocker ${blocker.id} - "${blocker.title}" (filed ${String(blocker.created_at).slice(0, 10)})`;
|
|
75
|
+
if (!hits.length) {
|
|
76
|
+
return [head, ' No shipped work since it was filed shares its subject. That is not proof it still holds: re-run the check it names, if any.'];
|
|
77
|
+
}
|
|
78
|
+
return [head, ` ${hits.length} shipped since it was filed may answer it — read these before asking again:`,
|
|
79
|
+
...hits.map((h) => ` - task ${h.id}${h.short_name ? ` - ${h.short_name}` : ''} (shipped ${String(h.shipped_at).slice(0, 10)}; shares: ${h.shared.join(', ')})${h.value_summary ? `\n ${h.value_summary}` : ''}`)];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function main(argv) {
|
|
83
|
+
const id = Number(argv[0]);
|
|
84
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
85
|
+
console.error('usage: node scripts/bongos/blocker-recheck.js <blocker-id> [--json]');
|
|
86
|
+
return 2;
|
|
87
|
+
}
|
|
88
|
+
const { cliClient } = require('./cli-lib');
|
|
89
|
+
const api = await cliClient();
|
|
90
|
+
const b = await api.request('GET', `/blockers/${id}`);
|
|
91
|
+
if (!b.ok || !b.data || !b.data.blocker) {
|
|
92
|
+
console.error(`blocker ${id}: could not be read (${b.status})`);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
const t = await api.request('GET', '/tasks?status=shipped&limit=1000');
|
|
96
|
+
if (!t.ok) {
|
|
97
|
+
console.error(`shipped tasks could not be read (${t.status}), so nothing was re-checked`);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
const tasks = Array.isArray(t.data) ? t.data : (t.data && t.data.tasks) || [];
|
|
101
|
+
const hits = relatedShipped(b.data.blocker, tasks);
|
|
102
|
+
if (argv.includes('--json')) console.log(JSON.stringify({ blocker_id: id, related: hits }, null, 2));
|
|
103
|
+
else console.log(reportLines(b.data.blocker, hits).join('\n'));
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { subjectWords, relatedShipped, reportLines };
|
|
108
|
+
|
|
109
|
+
if (require.main === module) {
|
|
110
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(e.message); process.exitCode = 1; });
|
|
111
|
+
}
|
|
@@ -801,12 +801,12 @@ async function runWalk() {
|
|
|
801
801
|
for (const stage of genesis.PROPER) {
|
|
802
802
|
store.tasks.get(STAGE_TASK_IDS[stage]).status = 'shipped';
|
|
803
803
|
let itemId = null;
|
|
804
|
-
await check(`${stage}: its finished stage is put to the board`, 'a sitting under the chosen government', async () => {
|
|
804
|
+
await check(`${stage}: its finished stage is put to the board`, '202 not closed, a sitting under the chosen government', async () => {
|
|
805
805
|
const r = await call(FOUNDER, 'POST', `/founding/stages/${stage}/close`);
|
|
806
806
|
itemId = r.json && r.json.item_id;
|
|
807
807
|
const snap = itemId && itemOf(itemId) ? itemOf(itemId).constitution : {};
|
|
808
|
-
const reads = charter.readCharter(snap).form;
|
|
809
|
-
return { got: `${r.status}, sitting under ${reads}`, pass: r.status ===
|
|
808
|
+
const reads = charter.readCharter(snap).form, notClosed = !!r.json && r.json.closed === false && r.json.reason === 'sitting_open'; // task 1005121: never a 200 for an open stage
|
|
809
|
+
return { got: `${r.status} ${notClosed ? 'not closed' : 'closed?'}, a sitting under ${reads}`, pass: r.status === 202 && notClosed && !!itemId && reads === CHOSEN };
|
|
810
810
|
});
|
|
811
811
|
await check(`${stage}: the founder alone cannot close it; every trusted member's yes does`, 'open, open, passed', async () => {
|
|
812
812
|
const after = await voteAll(itemId, timocrats);
|
|
@@ -1267,7 +1267,7 @@ async function walkNewMachinery({ call, code, vote, voteAll, check, section, sta
|
|
|
1267
1267
|
});
|
|
1268
1268
|
let matter = null;
|
|
1269
1269
|
await check('With an ask, the AI session\'s blocker is filed and carries its card', '201; the blocker reads back with its question', async () => {
|
|
1270
|
-
const r = await call(CY, 'POST', '/blockers', { title: 'Walk needs a decision', ask: GOOD_ASK }, 'cli');
|
|
1270
|
+
const r = await call(CY, 'POST', '/blockers', { title: 'Walk needs a decision', ask: GOOD_ASK, checked: 'Ran the walk against the stub server and read its refusal for this exact card.' }, 'cli');
|
|
1271
1271
|
const id = r.json && r.json.blocker && r.json.blocker.id;
|
|
1272
1272
|
matter = id ? `blockers:${id}` : null;
|
|
1273
1273
|
const back = await call(CY, 'GET', `/blockers/${id}`);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/blocker-recheck.js — @bongos-forwarder (task 1004848). Generated by
|
|
3
|
+
// scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
|
|
4
|
+
// The real script is scripts/bongos/blocker-recheck.js. This forwarder keeps callers that still name
|
|
5
|
+
// the old path working, and warns once so they get repointed. Task 1003706 removes it.
|
|
6
|
+
'use strict';
|
|
7
|
+
require('../bongos/forwarder.js').forward(module, 'blocker-recheck.js');
|
|
@@ -182,6 +182,7 @@ const FIXTURE_FALLBACKS = [
|
|
|
182
182
|
// and changes nothing: the harness has no state to change, and the page reads
|
|
183
183
|
// the same fixtures again on reload. Anything absent is still refused.
|
|
184
184
|
const CANNED_WRITES = [
|
|
185
|
+
['POST', /^inbox$/, 'inbox.post'],
|
|
185
186
|
['PUT', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/draft$/, 'copy-desk__pages__draft.put'],
|
|
186
187
|
['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/submit$/, 'copy-desk__pages__submit.post'],
|
|
187
188
|
['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/claim$/, 'copy-desk__pages__claim.post'],
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.24.
|
|
84
|
+
const CORE_VERSION = '1.24.76'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -238,6 +238,55 @@ test('a box with nothing newer offers no button', async () => {
|
|
|
238
238
|
const b = boot({ controlPlane: preview('1', 'cloudbongos', { behind: false, recommended_version: null }) });
|
|
239
239
|
await b.settle();
|
|
240
240
|
assert.match(b.panel(), /There is nothing to deploy/);
|
|
241
|
+
assert.doesNotMatch(b.panel(), /id="dp-preview-1"/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ---- the plain status line and the deploy-now button (task 1005012) ----------------
|
|
245
|
+
//
|
|
246
|
+
// The owner opened this page to put merged work live and found neither a button nor a
|
|
247
|
+
// sentence: the box was already on the newest version, which it had reached by itself on
|
|
248
|
+
// its update rule, and the page said only "nothing to deploy". So the box now LEADS with
|
|
249
|
+
// one plain line — up to date or not — and says how the next version arrives.
|
|
250
|
+
|
|
251
|
+
test('up to date: says so in words, names the version, and says the next one arrives on its own', async () => {
|
|
252
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { behind: false, recommended_version: null, served_version: '1.24.1', update_channel: 'minor' }) });
|
|
253
|
+
await b.settle();
|
|
254
|
+
const html = b.panel();
|
|
255
|
+
assert.match(html, /dp-status dp-status--ok/, 'a status line, in the ok tone');
|
|
256
|
+
assert.match(html, /<strong>Up to date\.<\/strong>/);
|
|
257
|
+
assert.match(html, /1\.24\.1/);
|
|
258
|
+
assert.match(html, /merged work inside core 1\.24\.1 is live/);
|
|
259
|
+
assert.match(html, /arrives on its own/, 'the update rule moves it, so nothing needs pressing');
|
|
260
|
+
assert.match(html, /not live until a published version carries it/, 'and merged work after it is still waiting');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('up to date on Stay put: says a newer version waits for a person, not that it arrives', async () => {
|
|
264
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { behind: false, recommended_version: null, update_channel: 'pinned' }) });
|
|
265
|
+
await b.settle();
|
|
266
|
+
const html = b.panel();
|
|
267
|
+
assert.match(html, /<strong>Up to date\.<\/strong>/);
|
|
268
|
+
assert.doesNotMatch(html, /arrives on its own/);
|
|
269
|
+
assert.match(html, /waits until you change the update rule/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('behind: a plain "not up to date" line and ONE primary deploy-now button', async () => {
|
|
273
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { update_channel: 'patch' }) });
|
|
274
|
+
await b.settle();
|
|
275
|
+
const html = b.panel();
|
|
276
|
+
assert.match(html, /dp-status dp-status--behind/);
|
|
277
|
+
assert.match(html, /<strong>Not up to date\.<\/strong> Core <strong>1\.19\.871<\/strong> is published; this project still runs 1\.19\.800\./);
|
|
278
|
+
assert.match(html, /class="btn btn--primary" id="dp-preview-1">Deploy core 1\.19\.871 now<\/button>/);
|
|
279
|
+
assert.doesNotMatch(html, /See what would change/, 'the old two-word step label is gone');
|
|
280
|
+
assert.equal((html.match(/btn--primary/g) || []).length, 1, 'exactly one primary action');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('the deploy-now button still leaves going live to a person: it opens the confirm, it moves nothing', async () => {
|
|
284
|
+
const b = boot();
|
|
285
|
+
await b.settle();
|
|
286
|
+
b.els.get('dp-preview-1')._on.click();
|
|
287
|
+
await b.settle();
|
|
288
|
+
assert.ok(b.posts.every((p) => p.body && p.body.dry_run === true), 'only the dry-run check is asked for');
|
|
289
|
+
assert.match(b.panel(), /id="dp-confirm-1"/, 'the person confirms the move themselves');
|
|
241
290
|
});
|
|
242
291
|
|
|
243
292
|
test('a box that did not answer is never rendered as up to date', async () => {
|
package/tests/genesis_home.mjs
CHANGED
|
@@ -205,8 +205,13 @@ test('decide → five stages through the Board Room → alive, and the hall open
|
|
|
205
205
|
w.finish(s);
|
|
206
206
|
assert.equal((await gh.readGenesis(w.deps())).can_close, true);
|
|
207
207
|
const closed = await gh.closeStage(s, 1, w.deps());
|
|
208
|
-
|
|
208
|
+
// task 1005121: a module without the one-call close only OPENS a sitting, and
|
|
209
|
+
// the answer must say so — never ok: true for a stage that did not close.
|
|
210
|
+
assert.equal(closed.ok, false);
|
|
211
|
+
assert.equal(closed.closed, false);
|
|
212
|
+
assert.equal(closed.reason, 'sitting_open');
|
|
209
213
|
assert.equal(closed.item_id, '9');
|
|
214
|
+
assert.match(closed.next, /moves on by itself/);
|
|
210
215
|
assert.deepEqual(w.opened.at(-1), { stageKey: s, taskIds: [String(100 + STAGES5.indexOf(s))] }, 'the stage\'s own task ids go to the gate');
|
|
211
216
|
g = await gh.readGenesis(w.deps());
|
|
212
217
|
assert.equal(g.stage, s, 'sitting is not passing: the stage holds until the vote');
|
|
@@ -355,3 +360,83 @@ test('the hall carries the five life plates byte-identical to the world\'s', ()
|
|
|
355
360
|
}
|
|
356
361
|
}
|
|
357
362
|
});
|
|
363
|
+
|
|
364
|
+
// ── task 1005121: closing a stage is ONE call ────────────────────────────────
|
|
365
|
+
|
|
366
|
+
function oneCallWorld({ solo = true } = {}) {
|
|
367
|
+
const w = world();
|
|
368
|
+
const calls = [];
|
|
369
|
+
w.deps = ((base) => () => {
|
|
370
|
+
const d = base();
|
|
371
|
+
d.government = {
|
|
372
|
+
...d.government,
|
|
373
|
+
closeGenesisStage: async ({ stageKey, taskIds, closedBy }) => {
|
|
374
|
+
calls.push({ stageKey, taskIds, closedBy });
|
|
375
|
+
if (!(w.tasks[stageKey] || []).every((t) => ['shipped', 'abandoned'].includes(t.status))) {
|
|
376
|
+
return { closed: false, reason: 'stage_tasks_unfinished', unfinished: [{ task_id: taskIds[0] }] };
|
|
377
|
+
}
|
|
378
|
+
if (solo) {
|
|
379
|
+
w.board[stageKey] = { state: 'passed', item_id: '9', waiting_on: [] };
|
|
380
|
+
return { closed: true, item: { id: 9 }, stage: { key: stageKey }, cast: { cast_via: 'stage_close', fresh: true } };
|
|
381
|
+
}
|
|
382
|
+
const status = {
|
|
383
|
+
key: stageKey, state: 'sitting', item_id: '9', pass_rule: 'consent', needs: 'Passes unless someone objects.',
|
|
384
|
+
voted: [], waiting_on: [{ builder_id: '1', display_name: 'Founder' }, { builder_id: '2', github_login: 'avery' }],
|
|
385
|
+
closes_at: 'T', remaining_seconds: 600,
|
|
386
|
+
};
|
|
387
|
+
w.board[stageKey] = status;
|
|
388
|
+
return { closed: false, item: { id: 9 }, stage: { key: stageKey }, opened: true, status, cast: null };
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
return d;
|
|
392
|
+
})(w.deps);
|
|
393
|
+
return { w, calls };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
test('task 1005121: one call closes the stage and moves the project on, at every stage, the same way', async () => {
|
|
397
|
+
const { w, calls } = oneCallWorld();
|
|
398
|
+
await gh.startFounding(1, w.deps());
|
|
399
|
+
await gh.savePlan({}, 1, w.deps());
|
|
400
|
+
for (const s of STAGES5) {
|
|
401
|
+
assert.equal((await gh.closeStage(s, 1, w.deps())).reason, 'stage_tasks_unfinished');
|
|
402
|
+
w.finish(s);
|
|
403
|
+
const r = await gh.closeStage(s, 1, w.deps());
|
|
404
|
+
assert.equal(r.ok, true, `${s}: one call`);
|
|
405
|
+
assert.equal(r.closed, true);
|
|
406
|
+
assert.equal(r.stage, s);
|
|
407
|
+
assert.equal(r.confirm, 'stage_close', "the founder's confirm carried it, and the answer says how");
|
|
408
|
+
const next = s === 'legislation' ? null : STAGES5[STAGES5.indexOf(s) + 1];
|
|
409
|
+
assert.equal(r.next_stage, next, 'the answer names the next stage');
|
|
410
|
+
const g = await gh.readGenesis(w.deps());
|
|
411
|
+
assert.equal(g.stage, next || 'legislation', 'no second request: the project has already moved on');
|
|
412
|
+
}
|
|
413
|
+
assert.ok(calls.every((c) => String(c.closedBy) === '1'), 'the caller is who confirms');
|
|
414
|
+
assert.equal((await gh.readGenesis(w.deps())).can_come_alive, true, 'legislation closed: the alive door is open');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('task 1005121: where a real sitting decides, the answer is not ok and says what it waits on and what to do', async () => {
|
|
418
|
+
const { w } = oneCallWorld({ solo: false });
|
|
419
|
+
await gh.startFounding(1, w.deps());
|
|
420
|
+
await gh.savePlan({}, 1, w.deps());
|
|
421
|
+
w.finish('scope');
|
|
422
|
+
const r = await gh.closeStage('scope', 1, w.deps());
|
|
423
|
+
assert.equal(r.ok, false, 'never ok: true for a stage that did not close');
|
|
424
|
+
assert.equal(r.closed, false);
|
|
425
|
+
assert.equal(r.reason, 'sitting_open');
|
|
426
|
+
assert.equal(r.item_id, '9');
|
|
427
|
+
assert.equal(r.waiting.pass_rule, 'consent');
|
|
428
|
+
assert.deepEqual(r.waiting.waiting_on.map((m) => m.builder_id), ['1', '2']);
|
|
429
|
+
assert.match(r.next, /Founder, avery/);
|
|
430
|
+
assert.ok(r.next.includes('Your vote is one of them: POST /api/bongos/government/board/items/9/votes'), r.next);
|
|
431
|
+
assert.equal((await gh.readGenesis(w.deps())).stage, 'scope', 'the stage holds until the sitting passes');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('task 1005121: the close route answers 202 with the whole body for an open sitting, never a 200', () => {
|
|
435
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'onboarding', 'routes', 'onboarding.js'), 'utf8');
|
|
436
|
+
const at = src.indexOf("router.post('/founding/stages/:stage/close'");
|
|
437
|
+
const route = src.slice(at, at + 700);
|
|
438
|
+
assert.ok(route.includes("r.reason === 'sitting_open') return res.status(202).json(r)"), 'an open sitting is a 202 with the whole body');
|
|
439
|
+
const hall = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis-home.js'), 'utf8');
|
|
440
|
+
const click = hall.slice(hall.indexOf("t.hasAttribute('data-genesis-close')"), hall.indexOf("t.hasAttribute('data-genesis-confirm')"));
|
|
441
|
+
assert.ok(click.length > 0 && !click.includes('/votes'),'the hall no longer casts a second vote after closing — the server did it');
|
|
442
|
+
});
|