@bongos/core 1.19.932 → 1.19.934
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 +44 -34
- package/docs/copy-inventory.md +14 -14
- package/docs/copy-registry.json +14 -14
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/board.js +32 -7
- package/modules/government/migrations/government_017_board_section_keys_v2.sql +59 -0
- package/modules/hall-ui/public/board-room.js +72 -15
- package/modules/ideas/migrations/ideas_008_template_default_v2.sql +37 -0
- package/modules/ideas/projection.js +16 -3
- package/modules/ideas/templates.js +80 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +48 -5
- package/scripts/gds/upgrade.js +9 -4
- package/src/module-api.js +1 -1
- package/tests/government_board_room_copy.mjs +12 -3
- package/tests/government_board_vote.mjs +53 -5
- package/tests/government_board_vote_ui.mjs +26 -6
- package/tests/government_seed.mjs +1 -0
- package/tests/helpers.mjs +23 -0
- package/tests/idea_completeness.mjs +30 -13
- package/tests/idea_grades.mjs +7 -2
- package/tests/idea_hall_composer.mjs +45 -5
- package/tests/idea_ideate_full.mjs +13 -4
- package/tests/idea_resubmit.mjs +49 -10
- package/tests/idea_spark_hall.mjs +3 -1
- package/tests/idea_templates.mjs +95 -16
- package/tests/update_subscription_engine.mjs +67 -5
- package/tests/upgrade.mjs +52 -0
- package/tests/upgrade_persist_pin.mjs +97 -3
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// TASK 1003270's two rules came with the card and still govern edits here:
|
|
28
28
|
// • NO RAW CONFIG KEY REACHES A READER. `first_ratifier` / `rank:archon` /
|
|
29
29
|
// `why_matters` are grammar for the server; a person gets the sentence
|
|
30
|
-
// (decidedByHtml, rankNames,
|
|
30
|
+
// (decidedByHtml, rankNames, SECTION_LABELS_BY_TEMPLATE). The single exception is a
|
|
31
31
|
// membership predicate that cannot be PARSED, where the broken string is
|
|
32
32
|
// the actionable fact.
|
|
33
33
|
// • COPY THAT EXPLAINS THE UI TO ITSELF IS DELETED, not shortened.
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
// `fetchJson` / `sendJson` stay per-page: a hall page script is a self-contained
|
|
51
51
|
// IIFE over the DOM, and those two carry this page's own error copy.
|
|
52
52
|
//
|
|
53
|
-
//
|
|
54
|
-
// only reader, and the lib is for what the two pages share.
|
|
53
|
+
// SECTION_LABELS_BY_TEMPLATE and fmtRemaining stay HERE, not in the lib — this
|
|
54
|
+
// page is their only reader, and the lib is for what the two pages share.
|
|
55
55
|
//
|
|
56
56
|
// The function ORDER here is deliberate and matches the pre-carve source:
|
|
57
57
|
// tests/government_board_room_copy.mjs slices this file by function-name
|
|
@@ -165,7 +165,7 @@
|
|
|
165
165
|
// wrote under and the objector picked from. `why_matters` is a jsonb key,
|
|
166
166
|
// not something a reader should have to decode.
|
|
167
167
|
const objection = v.direction === 'no'
|
|
168
|
-
? `<p class="ov-row__sub">objected to <strong>${escapeHtml(
|
|
168
|
+
? `<p class="ov-row__sub">objected to <strong>${escapeHtml(labelsOf(item)[v.section_key] || ANY_SECTION_LABEL[v.section_key] || v.section_key || 'the idea')}</strong>: ${escapeHtml(v.reason_md || '')}</p>`
|
|
169
169
|
: '';
|
|
170
170
|
return `<li class="ov-row ov-row--dense"><div class="ov-row__main">
|
|
171
171
|
<span class="ov-row__title">${who}${mine}</span>
|
|
@@ -178,17 +178,59 @@
|
|
|
178
178
|
|
|
179
179
|
// ---- the vote form (R17) -----------------------------------------------------
|
|
180
180
|
|
|
181
|
-
// The
|
|
181
|
+
// The section labels, mirrored from the template declarations
|
|
182
182
|
// (modules/ideas/templates.js — the words the author saw when writing; a test
|
|
183
183
|
// holds the two equal, since browser code cannot import a server module).
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
184
|
+
//
|
|
185
|
+
// ⚠ ONE MAP PER VINTAGE, KEYED BY `template_key` (task 1004085 / BV2.R06).
|
|
186
|
+
// There used to be one flat map, which was right while there was one template.
|
|
187
|
+
// It cannot be flattened again: `why_had` and `fit` exist in BOTH vintages with
|
|
188
|
+
// DIFFERENT questions behind them, so a merged map would show one idea's author
|
|
189
|
+
// the words a different author was asked. The label is a quotation, and the
|
|
190
|
+
// thing it quotes is the template the subject was actually written against.
|
|
191
|
+
const SECTION_LABELS_BY_TEMPLATE = {
|
|
192
|
+
'base-v1': {
|
|
193
|
+
purpose: 'Purpose of Idea',
|
|
194
|
+
big_picture: 'What does the Big Picture look like',
|
|
195
|
+
why_had: 'Why did you have the Idea',
|
|
196
|
+
why_matters: 'Why does the Idea Matter',
|
|
197
|
+
fit: 'How does the Idea Fit into the big picture of the project',
|
|
198
|
+
},
|
|
199
|
+
'base-v2': {
|
|
200
|
+
why_had: 'What made you have the idea',
|
|
201
|
+
scope: 'Scope it as best as you can here',
|
|
202
|
+
build_context: 'What should an engineer or artist know prior to building',
|
|
203
|
+
fit: 'How is this long term justified in the project',
|
|
204
|
+
success: 'What does a successful implementation of the idea look like',
|
|
205
|
+
},
|
|
190
206
|
};
|
|
191
207
|
|
|
208
|
+
// The vintage an item's sections should be read under.
|
|
209
|
+
//
|
|
210
|
+
// THE FALLBACK IS `base-v1`, AND IT IS NOT THE SERVER'S DEFAULT. An item with
|
|
211
|
+
// no template to read is an AMENDMENT (which has no Full Idea behind it at all,
|
|
212
|
+
// yet still needs a section picker, because the DB requires every objection to
|
|
213
|
+
// name one) or a full_idea whose subject row was deleted. Falling back to the
|
|
214
|
+
// current default would silently change what those items have always offered;
|
|
215
|
+
// falling back to v1 keeps them byte-identical to before this task. A vintage
|
|
216
|
+
// this shipped page has never heard of falls here too — see sectionsOf.
|
|
217
|
+
function templateOf(item) {
|
|
218
|
+
const key = item && item.subject && item.subject.template_key;
|
|
219
|
+
return SECTION_LABELS_BY_TEMPLATE[key] ? key : 'base-v1';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function labelsOf(item) {
|
|
223
|
+
return SECTION_LABELS_BY_TEMPLATE[templateOf(item)];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// DECODE-ONLY, and deliberately not used to render a form. A stored objection
|
|
227
|
+
// names a section that was legal when it was cast, which on an item whose
|
|
228
|
+
// vintage this page cannot resolve is a key no per-vintage map holds. Turning
|
|
229
|
+
// it into SOME readable words beats printing `why_matters` at a reader, and the
|
|
230
|
+
// ambiguity the per-vintage maps exist to avoid does not arise here: the item's
|
|
231
|
+
// own map is consulted first, and this is only the fallback behind it.
|
|
232
|
+
const ANY_SECTION_LABEL = Object.assign({}, ...Object.values(SECTION_LABELS_BY_TEMPLATE));
|
|
233
|
+
|
|
192
234
|
// The idea's five sections, IN FRONT of the voter — the entire reason voting
|
|
193
235
|
// happens in the hall rather than on a reaction. Rendered in template order.
|
|
194
236
|
//
|
|
@@ -202,12 +244,20 @@
|
|
|
202
244
|
function ideaSectionsHtml(item) {
|
|
203
245
|
const fv = item.subject && item.subject.field_values;
|
|
204
246
|
if (!fv) return '';
|
|
205
|
-
const
|
|
247
|
+
const labels = labelsOf(item);
|
|
248
|
+
// The item's own vintage first, in its declared order — then anything the
|
|
249
|
+
// author wrote that this page has no label for. That tail is normally empty
|
|
250
|
+
// and exists so a vintage shipped after this page cannot make an author's
|
|
251
|
+
// answers INVISIBLE to the board being asked to ratify them: an unlabelled
|
|
252
|
+
// section reads as its key, which is ugly and honest, where dropping it is
|
|
253
|
+
// neither. (The vote FORM deliberately does not do this — see voteFormHtml.)
|
|
254
|
+
const extra = Object.keys(fv).filter((k) => !(k in labels));
|
|
255
|
+
const rows = Object.keys(labels).concat(extra).map((key) => {
|
|
206
256
|
// An unanswered section is dropped rather than rendered as a bare heading:
|
|
207
257
|
// a graded Full Idea has all five, and a blank one is only noise.
|
|
208
258
|
const body = String(fv[key] ?? '').trim();
|
|
209
259
|
if (!body) return '';
|
|
210
|
-
return `<h4 class="ov-prose__l">${escapeHtml(
|
|
260
|
+
return `<h4 class="ov-prose__l">${escapeHtml(labels[key] || key)}</h4>
|
|
211
261
|
<p class="ov-prose__p gov-idea__body">${escapeHtml(body)}</p>`;
|
|
212
262
|
}).join('');
|
|
213
263
|
return rows
|
|
@@ -268,8 +318,15 @@
|
|
|
268
318
|
// discovered by clicking.
|
|
269
319
|
const isOwnIdea = item.subject_type === 'full_idea'
|
|
270
320
|
&& item.subject && String(item.subject.author_id) === String(viewerId);
|
|
271
|
-
|
|
272
|
-
|
|
321
|
+
// THE PICKER OFFERS THE ITEM'S OWN VINTAGE, AND ONLY KEYS THE SERVER ACCEPTS.
|
|
322
|
+
// Unlike the prose above it does NOT fall through to whatever the author
|
|
323
|
+
// wrote: government_003's CHECK (widened by government_017) is a closed
|
|
324
|
+
// vocabulary, so offering a section outside it would render a radio whose
|
|
325
|
+
// only outcome is a refused vote. A vintage this page cannot resolve keeps
|
|
326
|
+
// base-v1's five — see templateOf.
|
|
327
|
+
const labels = labelsOf(item);
|
|
328
|
+
const sections = Object.keys(labels).map((key) => `
|
|
329
|
+
<label class="gov-vote__section"><input type="radio" name="vote-section-${escapeHtml(item.id)}" value="${escapeHtml(key)}"> <span>${escapeHtml(labels[key])}</span></label>`).join('');
|
|
273
330
|
const ownNote = isOwnIdea
|
|
274
331
|
? `<p class="gov-grid-note">Your own idea. You may vote, but it can’t be decided by your vote alone, and a vote on your own idea earns no karma.</p>`
|
|
275
332
|
: '';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- ideas_008_template_default_v2.sql — a NEW Full Idea is written against
|
|
2
|
+
-- `base-v2` (task 1004085 / BV2.R06, goal 1000095; spec decision 10 in
|
|
3
|
+
-- docs/specs/bongos-v2-ideator-surface.md).
|
|
4
|
+
--
|
|
5
|
+
-- WHAT THIS DOES NOT DO: it does not touch a single stored row. `template_key`
|
|
6
|
+
-- has been NOT NULL with a value on every row since ideas_001, and every writer
|
|
7
|
+
-- passes the key explicitly, so there is nothing to backfill and nothing to
|
|
8
|
+
-- reinterpret. The hundreds of ideas already filed keep saying 'base-v1' and keep
|
|
9
|
+
-- being rendered and scored against the questions they were actually asked —
|
|
10
|
+
-- which is the entire promise of versioning the template rather than editing it.
|
|
11
|
+
-- A backfill here would be the bug, not the missing half: `completeness_score`
|
|
12
|
+
-- feeds the ideator credit lane (R147), so re-scoring old thoughts against new
|
|
13
|
+
-- questions would quietly change what work already paid.
|
|
14
|
+
--
|
|
15
|
+
-- WHY MOVE THE COLUMN DEFAULT AT ALL, then. Belt and braces. The default is
|
|
16
|
+
-- unreachable in normal operation — modules/ideas/grades.js always names the key,
|
|
17
|
+
-- taking it from projection.js's DEFAULT_TEMPLATE_KEY, which this migration
|
|
18
|
+
-- mirrors. But a column default is a STATEMENT about what a row without an
|
|
19
|
+
-- opinion is, and leaving it at 'base-v1' would leave that statement false and
|
|
20
|
+
-- waiting: the first writer that ever omits the column would silently mint a new
|
|
21
|
+
-- idea wearing a retired vintage, validate its answers against the wrong five
|
|
22
|
+
-- questions, and score it against the wrong floors. Two statements of one fact
|
|
23
|
+
-- are only safe while they agree.
|
|
24
|
+
--
|
|
25
|
+
-- ALTER … SET DEFAULT is metadata only: no table rewrite, no lock held on the
|
|
26
|
+
-- rows, safe on a live instance of any size.
|
|
27
|
+
--
|
|
28
|
+
-- Additive and forward-safe. There is no down-migration, and the way back is not
|
|
29
|
+
-- this file run in reverse: 'base-v1' stays registered in modules/ideas/
|
|
30
|
+
-- templates.js forever, so reverting the default is a one-line code change with
|
|
31
|
+
-- no data consequence either way.
|
|
32
|
+
|
|
33
|
+
BEGIN;
|
|
34
|
+
|
|
35
|
+
ALTER TABLE ideas_grades ALTER COLUMN template_key SET DEFAULT 'base-v2';
|
|
36
|
+
|
|
37
|
+
COMMIT;
|
|
@@ -57,9 +57,22 @@ const VALID_GRADES = ['quick', 'spark', 'full'];
|
|
|
57
57
|
|
|
58
58
|
// Which template `field_values` was written against. Stored per row so a project
|
|
59
59
|
// that later defines its own shape (R156) does not retro-reinterpret ideas
|
|
60
|
-
// written against the base one.
|
|
61
|
-
// this module only records which template a row used.
|
|
62
|
-
|
|
60
|
+
// written against the base one. `templates.js` declares what each key actually
|
|
61
|
+
// contains — this module only records which template a row used.
|
|
62
|
+
//
|
|
63
|
+
// THIS IS THE DEFAULT FOR A NEW FILING ONLY, NEVER A READING OF A STORED ROW.
|
|
64
|
+
// It moved 'base-v1' -> 'base-v2' in task 1004085 (BV2.R06, spec decision 10:
|
|
65
|
+
// the five questions are replaced and versioned), and that move is safe for
|
|
66
|
+
// exactly one reason — every graded row carries its own `template_key`, so the
|
|
67
|
+
// hundreds already stored keep resolving to the v1 declaration and keep being
|
|
68
|
+
// scored against the questions they were put. Anything that reaches for this
|
|
69
|
+
// constant while holding a row is a bug: read `row.template_key`.
|
|
70
|
+
//
|
|
71
|
+
// `ideas_008_template_default_v2.sql` moves the COLUMN default to match. The two
|
|
72
|
+
// are belt and braces — every writer passes the key explicitly — but a column
|
|
73
|
+
// default that still said 'base-v1' would be a false statement about what a new
|
|
74
|
+
// idea is, waiting for the first writer that forgets.
|
|
75
|
+
const DEFAULT_TEMPLATE_KEY = 'base-v2';
|
|
63
76
|
const TEMPLATE_KEY_RE = /^[a-z][a-z0-9-]*$/;
|
|
64
77
|
const TEMPLATE_KEY_MAX = 64;
|
|
65
78
|
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
// modules/ideas/templates.js — the Full Idea template declarations (task 1002917,
|
|
2
2
|
// BV1.R141, goal 1000062).
|
|
3
3
|
//
|
|
4
|
-
// THIS FILE IS WHAT
|
|
5
|
-
// jsonb against a `template_key` and deliberately declares nothing
|
|
6
|
-
// its header says "R141 declares what 'base-v1' means". This is that
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// THIS FILE IS WHAT EVERY `template_key` MEANS. ideas_001_grades.sql stores
|
|
5
|
+
// `field_values` jsonb against a `template_key` and deliberately declares nothing
|
|
6
|
+
// about shape — its header says "R141 declares what 'base-v1' means". This is that
|
|
7
|
+
// declaration, and since task 1004085 (BV2.R06) there are TWO of them: `base-v2`,
|
|
8
|
+
// the questions a Full Idea is asked today, and `base-v1` beside it, the questions
|
|
9
|
+
// the ideas already in the table were actually asked. Each is the owner's own
|
|
10
|
+
// wording, in the order they display.
|
|
11
|
+
//
|
|
12
|
+
// A VINTAGE IS ADDITIVE AND FROZEN. A new question set is a new key in the
|
|
13
|
+
// registry and a new default; the old declaration is never edited and never
|
|
14
|
+
// removed, because the scorer walks whichever declaration a stored row names and
|
|
15
|
+
// `completeness_score` feeds credits. Re-asking is free; re-scoring is not.
|
|
9
16
|
//
|
|
10
17
|
// THE TEMPLATE IS DATA, READ BY BOTH VALIDATION AND SCORING. Capture (R143)
|
|
11
18
|
// validates a submitted `field_values` against a template with
|
|
@@ -43,7 +50,7 @@ const { DEFAULT_TEMPLATE_KEY, FIELD_VALUES_MAX } = require('./inbox');
|
|
|
43
50
|
// answered, not padded past" — declared here as data so the scorer stays
|
|
44
51
|
// template-agnostic; validation deliberately does NOT enforce it (a thin answer
|
|
45
52
|
// is a scoring problem, not a capture error — scoring never blocks capture).
|
|
46
|
-
const
|
|
53
|
+
const BASE_V1_TEMPLATE = Object.freeze({
|
|
47
54
|
key: 'base-v1',
|
|
48
55
|
label: 'Full Idea',
|
|
49
56
|
fields: Object.freeze([
|
|
@@ -55,21 +62,81 @@ const BASE_TEMPLATE = Object.freeze({
|
|
|
55
62
|
]),
|
|
56
63
|
});
|
|
57
64
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
65
|
+
// THE QUESTIONS A FULL IDEA IS ASKED TODAY (task 1004085 / BV2.R06, goal 1000095;
|
|
66
|
+
// spec decision 10, `docs/specs/bongos-v2-ideator-surface.md`). The owner's own
|
|
67
|
+
// set, in the owner's own order.
|
|
68
|
+
//
|
|
69
|
+
// WHY A SECOND TEMPLATE RATHER THAN AN EDIT TO THE FIRST. Every graded row stores
|
|
70
|
+
// the `template_key` it was written against, and the scorer walks the declaration
|
|
71
|
+
// that key resolves to — so editing `base-v1` in place would re-score hundreds of
|
|
72
|
+
// already-filed ideas against questions nobody asked them, and `completeness_score`
|
|
73
|
+
// feeds the ideator credit lane (R147). A thought is scored on the questions it
|
|
74
|
+
// was actually put. `base-v1` above is therefore FROZEN HISTORY: it is never
|
|
75
|
+
// edited again, only read.
|
|
76
|
+
//
|
|
77
|
+
// TWO KEYS ARE REUSED DELIBERATELY. `why_had` survives outright ("what made you
|
|
78
|
+
// have the idea") and `fit` survives roughly (the long-term justification moved
|
|
79
|
+
// from "how does it fit" to "how is it justified long term"). Reusing the key is
|
|
80
|
+
// what makes this a registry entry instead of a data migration; `template_key` is
|
|
81
|
+
// what keeps the two readings apart, so a v1 row's `fit` is still rendered under
|
|
82
|
+
// the v1 label. `purpose`, `big_picture` and `why_matters` are NOT carried over —
|
|
83
|
+
// they belong to v1 and stay there.
|
|
84
|
+
//
|
|
85
|
+
// THE FLOORS ARE FLAT 40, MATCHING v1, AND THAT IS TEMPORARY. The new questions
|
|
86
|
+
// are not interchangeable ("scope it as best as you can" deserves more room than
|
|
87
|
+
// "what made you have the idea"), but the score feeds credits, so choosing the
|
|
88
|
+
// numbers is its own deliberate act — task 1004091 (BV2.R12) owns it. Shipping a
|
|
89
|
+
// guess here would have been an unreviewed change to what a thought pays.
|
|
90
|
+
const BASE_V2_TEMPLATE = Object.freeze({
|
|
91
|
+
key: 'base-v2',
|
|
92
|
+
label: 'Full Idea',
|
|
93
|
+
fields: Object.freeze([
|
|
94
|
+
Object.freeze({ key: 'why_had', label: 'What made you have the idea', required: true, substanceMin: 40 }),
|
|
95
|
+
Object.freeze({ key: 'scope', label: 'Scope it as best as you can here', required: true, substanceMin: 40 }),
|
|
96
|
+
Object.freeze({ key: 'build_context', label: 'What should an engineer or artist know prior to building', required: true, substanceMin: 40 }),
|
|
97
|
+
Object.freeze({ key: 'fit', label: 'How is this long term justified in the project', required: true, substanceMin: 40 }),
|
|
98
|
+
Object.freeze({ key: 'success', label: 'What does a successful implementation of the idea look like', required: true, substanceMin: 40 }),
|
|
99
|
+
]),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// The template a NEW Full Idea is written against — an alias, not a sixth
|
|
103
|
+
// declaration. Consumers that mean "the current questions" (scripts/gds/spark.js
|
|
104
|
+
// prints them; the tests build a full payload from them) read this, so the alias
|
|
105
|
+
// is what makes flipping the default a one-line change here rather than a sweep.
|
|
106
|
+
// Anything that means a SPECIFIC vintage names `BASE_V1_TEMPLATE` /
|
|
107
|
+
// `BASE_V2_TEMPLATE` instead.
|
|
108
|
+
const BASE_TEMPLATE = BASE_V2_TEMPLATE;
|
|
109
|
+
|
|
110
|
+
// The built-in registry. Two entries: what ideas are asked now, and what they
|
|
111
|
+
// were asked before. Frozen so a consumer can never mutate a declaration into a
|
|
112
|
+
// de-facto fork — a different shape is a different registry (the seam above).
|
|
113
|
+
// AN ENTRY IS NEVER REMOVED FROM HERE while rows still name it: `templateFor`
|
|
114
|
+
// returns null for an unknown key, and a null template is how a stored idea
|
|
115
|
+
// loses its questions and its score at once.
|
|
61
116
|
const BUILTIN_TEMPLATES = Object.freeze({
|
|
62
|
-
[
|
|
117
|
+
[BASE_V1_TEMPLATE.key]: BASE_V1_TEMPLATE,
|
|
118
|
+
[BASE_V2_TEMPLATE.key]: BASE_V2_TEMPLATE,
|
|
63
119
|
});
|
|
64
120
|
|
|
65
121
|
// Sanity pin, not a runtime branch: the store's default key and this file's
|
|
66
|
-
//
|
|
122
|
+
// current declaration are two statements of the same fact, and requiring them to
|
|
67
123
|
// agree at load time turns a rename-drift into a boot failure instead of a
|
|
68
124
|
// silent "every new row validates against a template that does not exist".
|
|
69
125
|
if (BASE_TEMPLATE.key !== DEFAULT_TEMPLATE_KEY) {
|
|
70
126
|
throw new Error(`templates: BASE_TEMPLATE.key (${BASE_TEMPLATE.key}) !== inbox DEFAULT_TEMPLATE_KEY (${DEFAULT_TEMPLATE_KEY})`);
|
|
71
127
|
}
|
|
72
128
|
|
|
129
|
+
// The second half of the same pin, and the one this task exists to hold: every
|
|
130
|
+
// vintage a row can still carry must stay resolvable. The default moving forward
|
|
131
|
+
// is a feature; a retired template silently dropping out of the registry is the
|
|
132
|
+
// failure mode — it would leave already-filed ideas with no questions to render
|
|
133
|
+
// and no floors to score against.
|
|
134
|
+
for (const t of [BASE_V1_TEMPLATE, BASE_V2_TEMPLATE]) {
|
|
135
|
+
if (!Object.hasOwn(BUILTIN_TEMPLATES, t.key)) {
|
|
136
|
+
throw new Error(`templates: ${t.key} is declared but not registered — an already-filed idea would lose its questions`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
73
140
|
// Resolve a template by key. Null for an unknown key — the caller decides
|
|
74
141
|
// whether that is a 400 (capture) or a render-as-raw fallback (display).
|
|
75
142
|
function templateFor(key, registry = BUILTIN_TEMPLATES) {
|
|
@@ -144,6 +211,8 @@ function validateFieldValues(fieldValues, { templateKey = DEFAULT_TEMPLATE_KEY,
|
|
|
144
211
|
|
|
145
212
|
module.exports = {
|
|
146
213
|
BASE_TEMPLATE,
|
|
214
|
+
BASE_V1_TEMPLATE,
|
|
215
|
+
BASE_V2_TEMPLATE,
|
|
147
216
|
BUILTIN_TEMPLATES,
|
|
148
217
|
templateFor,
|
|
149
218
|
fieldOrder,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.934",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.934",
|
|
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.
|
|
3
|
+
"version": "1.19.934",
|
|
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",
|
|
@@ -32,7 +32,7 @@ const path = require('node:path');
|
|
|
32
32
|
const { spawnSync } = require('node:child_process');
|
|
33
33
|
const { VERBS } = require('../../bin/bongos.js'); // side-effect-free require (guarded main)
|
|
34
34
|
const ic = require('../../src/instance-config'); // neutral configDir/envPrefix fallbacks (never a founder literal)
|
|
35
|
-
const { isCorePackageName } = require('../../src/module-loader/catalog'); // one definition of "this checkout IS the core"
|
|
35
|
+
const { isCorePackageName, CORE_PACKAGE_NAMES } = require('../../src/module-loader/catalog'); // one definition of "this checkout IS the core"
|
|
36
36
|
const { renderInstanceClaudeMd } = require('./claude-md-template'); // task 2104 — the starter CLAUDE.md
|
|
37
37
|
|
|
38
38
|
// script basename (e.g. "ship.js") → dedicated bongos verb, for verbs that map to a bare script with
|
|
@@ -630,11 +630,19 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
630
630
|
summary.notes.push('no .instance-skills.json provenance in .claude/skills/ yet, so nothing was withdrawn — this run records it; a later run can withdraw the skills of a module that has since been disabled.');
|
|
631
631
|
}
|
|
632
632
|
// Record what landed, so the NEXT run can withdraw what this one placed.
|
|
633
|
-
|
|
633
|
+
//
|
|
634
|
+
// REPORTED on a dry run, written only on a real one — the same split the starter CLAUDE.md
|
|
635
|
+
// below already uses. writtenPaths is a "what this run rewrites" list, and a dry run's job is
|
|
636
|
+
// to preview exactly that: upgrade.js asks for one to decide which dirty paths its clean-tree
|
|
637
|
+
// pre-flight may ignore (task 1004122), so a generated file missing from the preview is a file
|
|
638
|
+
// the pre-flight halts on and the real run then rewrites anyway.
|
|
639
|
+
if (overwrite) {
|
|
634
640
|
const names = Object.keys(landedNow).sort();
|
|
635
641
|
const manifest = { _doc: 'GENERATED by scripts/gds/claude-materialize.js (task 1003632): the module-declared skills a materialize landed in this instance. It is the provenance record that lets a later run WITHDRAW a skill whose module has since been disabled — a name absent from here is never deleted, so a hand-written skill is safe. Commit it; do not hand-edit.', skills: Object.fromEntries(names.map((n) => [n, landedNow[n]])) };
|
|
636
|
-
|
|
637
|
-
|
|
642
|
+
if (!dryRun) {
|
|
643
|
+
fs.mkdirSync(skillsDest, { recursive: true });
|
|
644
|
+
fs.writeFileSync(path.join(skillsDest, INSTANCE_SKILLS_MANIFEST), JSON.stringify(manifest, null, 2) + '\n');
|
|
645
|
+
}
|
|
638
646
|
addWritten(path.join('.claude/skills', INSTANCE_SKILLS_MANIFEST));
|
|
639
647
|
}
|
|
640
648
|
|
|
@@ -741,6 +749,41 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
741
749
|
return summary;
|
|
742
750
|
}
|
|
743
751
|
|
|
752
|
+
// materializeWillRewrite — the instance-relative paths a materialize WOULD rewrite here, changing
|
|
753
|
+
// nothing. `bongos upgrade` asks before it starts, to decide which dirty paths its clean-tree
|
|
754
|
+
// pre-flight may waive.
|
|
755
|
+
//
|
|
756
|
+
// THE WEDGE THIS EXISTS FOR (task 1004122). The upgrade's materialize step rewrites `.claude/`
|
|
757
|
+
// from the new core on EVERY bump, so after an upgrade that did not commit it — one from before
|
|
758
|
+
// task 1003957, or any run without --commit-pin — the instance carries ~40 modified
|
|
759
|
+
// `.claude/skills/*` files that nobody edited. upgrade.js's clean-tree pre-flight then read them
|
|
760
|
+
// as a human's uncommitted work and refused, so the instance took exactly one upgrade and stuck:
|
|
761
|
+
// `test4` (instance 11) failed its Deploy-page upgrade this way, and provision-core-upgrade.js
|
|
762
|
+
// passes --commit-pin but NOT --force, so the button stayed dead until someone committed by hand.
|
|
763
|
+
//
|
|
764
|
+
// Asking materializeClaude what it WOULD write is a real provenance answer, not a path
|
|
765
|
+
// allow-list: the set is derived from the core actually installed here, and every path in it
|
|
766
|
+
// is one the materialize step overwrites unconditionally a few seconds later. Whatever those files hold right
|
|
767
|
+
// now is discarded either way, so refusing on them protects nothing and stops every future
|
|
768
|
+
// update. A path materialize would NOT touch — an owner's own skill, a dirty migration, a
|
|
769
|
+
// half-finished edit — is absent from the set and still halts the bump, which is the whole
|
|
770
|
+
// safety property. Under --commit-pin the same paths come back from that step as persistPin's
|
|
771
|
+
// artifactPaths and are committed, so the tree ends clean and the next upgrade needs none of this.
|
|
772
|
+
//
|
|
773
|
+
// Probed against the core on disk NOW, not the target: the target is not installed yet, and the
|
|
774
|
+
// two ship the same `.claude/` surface. Fail-soft — a materialize that throws (no core
|
|
775
|
+
// installed, an unreadable tree) returns nothing and the pre-flight is as strict as it ever was.
|
|
776
|
+
function materializeWillRewrite({ instanceDir, coreRoot, materialize = materializeClaude }, err = console.error) {
|
|
777
|
+
try {
|
|
778
|
+
const root = coreRoot || path.join(instanceDir, 'node_modules', CORE_PACKAGE_NAMES[0]);
|
|
779
|
+
const sum = materialize({ coreRoot: root, instanceDir, dryRun: true, log: () => {} });
|
|
780
|
+
return (sum && Array.isArray(sum.writtenPaths)) ? sum.writtenPaths : [];
|
|
781
|
+
} catch (e) {
|
|
782
|
+
err(` ! could not preview the .claude/ materialization (${e && e.message ? e.message : e}) — pre-flight will treat every dirty path as a human's work.`);
|
|
783
|
+
return [];
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
744
787
|
// ---- the dev checkout: module-owned skills materialised INTO the core (task 1003330, ADR 0224) ----
|
|
745
788
|
// materializeClaude's self-copy guard is right for init/upgrade (instance == core is a no-op),
|
|
746
789
|
// but Claude Code discovers only .claude/skills/ at the project root, so a builder working IN
|
|
@@ -850,4 +893,4 @@ if (require.main === module) {
|
|
|
850
893
|
process.exit(s.refused.length ? 1 : 0);
|
|
851
894
|
}
|
|
852
895
|
|
|
853
|
-
module.exports = { scriptToVerb, bongosify, transformSettings, readSettingsTransform, renderStarterSettings, STARTER_ALLOW, copyTreeTransformed, materializeClaude, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, readInstanceSkillsManifest, INSTANCE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
|
|
896
|
+
module.exports = { scriptToVerb, bongosify, transformSettings, readSettingsTransform, renderStarterSettings, STARTER_ALLOW, copyTreeTransformed, materializeClaude, materializeWillRewrite, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, readInstanceSkillsManifest, INSTANCE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -129,11 +129,13 @@ function restorePin(instanceDir, depString, fsImpl = fs) {
|
|
|
129
129
|
|
|
130
130
|
// ---- steps (each injectable for tests) --------------------------------------
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
// `ignore` is waived (it comes from — and is argued for in — claude-materialize.js materializeWillRewrite, task 1004122); every OTHER dirty path still halts the bump.
|
|
133
|
+
function gitTreeClean(instanceDir, run = spawnSync, ignore = []) {
|
|
133
134
|
const r = run('git', ['status', '--porcelain'], { cwd: instanceDir, encoding: 'utf8' });
|
|
134
135
|
if (!r || r.error || r.status !== 0) return { ok: false, reason: 'git status failed (not a git repo?)' };
|
|
135
|
-
const
|
|
136
|
-
|
|
136
|
+
const skip = new Set(ignore), all = (r.stdout || '').split('\n').filter((l) => l.trim());
|
|
137
|
+
const kept = all.filter((l) => !skip.has(l.slice(3).trim()));
|
|
138
|
+
return { ok: !kept.length, dirty: kept.join('\n'), ignored: all.length - kept.length };
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
// ---- persisting the pin (task 1002712) --------------------------------------
|
|
@@ -1114,8 +1116,11 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1114
1116
|
|
|
1115
1117
|
// 1. pre-flight: clean tree (so the pin change is reviewable + revertable) + module compatibility.
|
|
1116
1118
|
if (!opts.force) {
|
|
1117
|
-
|
|
1119
|
+
// Step 5's own output is not dirt (task 1004122) — but only when step 5 is going to run.
|
|
1120
|
+
const preview = opts.skipMaterialize ? () => [] : (deps.willRewrite || require('./claude-materialize').materializeWillRewrite);
|
|
1121
|
+
const tree = gitTreeClean(instanceDir, run, preview({ instanceDir, materialize }, err));
|
|
1118
1122
|
if (!tree.ok) return { ok: false, error: `working tree not clean — commit/stash first, or pass --force.\n${tree.dirty || tree.reason || ''}` };
|
|
1123
|
+
if (tree.ignored) log(` • pre-flight: ignored ${tree.ignored} dirty path(s) this upgrade rewrites from the core (.claude/ materialization)${opts.commitPin ? ' — they are committed with the pin' : ' — pass --commit-pin to commit them'}.`);
|
|
1119
1124
|
}
|
|
1120
1125
|
const mods = preflightModules({ instanceDir, targetVersion }, deps);
|
|
1121
1126
|
log(` ✓ pre-flight: ${mods.note}`);
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.934'; // 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');
|
|
@@ -138,8 +138,15 @@ test('the self-explaining copy is DELETED, not reworded', () => {
|
|
|
138
138
|
|
|
139
139
|
test('the section a voter faults is shown by NAME, in the ballot and the picker', () => {
|
|
140
140
|
const ballot = BOARD.slice(BOARD.indexOf('function ballotHtml'), BOARD.indexOf('// ---- the vote form'));
|
|
141
|
-
|
|
141
|
+
// Since task 1004085 the label comes from the item's OWN vintage — `why_had`
|
|
142
|
+
// and `fit` exist in both templates asking different questions, so a single
|
|
143
|
+
// flat map would show the reader the wrong sentence. `ANY_SECTION_LABEL` is
|
|
144
|
+
// the decode-only fallback behind it, for a section whose vintage this page
|
|
145
|
+
// cannot resolve; the one thing that must never appear is the bare key.
|
|
146
|
+
assert.match(ballot, /labelsOf\(item\)\[v\.section_key\]/,
|
|
142
147
|
'`why_matters` is a jsonb key; the objector picked a named section and the reader should see that name');
|
|
148
|
+
assert.match(ballot, /ANY_SECTION_LABEL\[v\.section_key\]/,
|
|
149
|
+
'and an older vintage still decodes to words rather than to its key');
|
|
143
150
|
});
|
|
144
151
|
|
|
145
152
|
test('the objection still carries a reason AND one section (ADR 0175 §8)', () => {
|
|
@@ -147,8 +154,10 @@ test('the objection still carries a reason AND one section (ADR 0175 §8)', () =
|
|
|
147
154
|
// hall instead of on a chat reaction.
|
|
148
155
|
const form = BOARD.slice(BOARD.indexOf('function voteFormHtml'), BOARD.indexOf('async function castVoteFromRoom'));
|
|
149
156
|
assert.match(form, /data-vote-reason/, 'the reason box survives');
|
|
150
|
-
assert.match(form, /gov-vote__sections/, 'so does the
|
|
151
|
-
assert.match(form, /Object\.keys\(
|
|
157
|
+
assert.match(form, /gov-vote__sections/, 'so does the section picker');
|
|
158
|
+
assert.match(form, /Object\.keys\(labels\)\.map/, 'with one radio per declared section');
|
|
159
|
+
assert.match(form, /const labels = labelsOf\(item\)/,
|
|
160
|
+
'and the sections offered are the ones the subject was actually written against');
|
|
152
161
|
});
|
|
153
162
|
|
|
154
163
|
test('each room frames itself in one line, not a paragraph', () => {
|
|
@@ -29,19 +29,44 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
29
29
|
const GOV = path.join(ROOT, 'modules', 'government');
|
|
30
30
|
const board = require(path.join(GOV, 'board.js'));
|
|
31
31
|
const db = require(path.join(GOV, 'db.js'));
|
|
32
|
-
const { fieldOrder, templateFor } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
|
|
32
|
+
const { fieldOrder, templateFor, BUILTIN_TEMPLATES } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
|
|
33
33
|
|
|
34
34
|
const { FULL_IDEA_SECTION_KEYS, VOTE_KARMA_DELTA, AUTHOR_RATIFY_KARMA_DELTA, awardAuthorRatifyKarma, validateVote, castVote } = board;
|
|
35
35
|
|
|
36
36
|
// ── invariant 1: one list, three places ─────────────────────────────────────
|
|
37
37
|
|
|
38
|
+
// THE LIST IS THE UNION OF EVERY VINTAGE, since task 1004085 (BV2.R06) versioned
|
|
39
|
+
// the Full Idea questions. It was `base-v1`'s five, and pinning it to one
|
|
40
|
+
// template again would re-open the defect that task closed: an idea filed under
|
|
41
|
+
// a newer vintage reaches the board, and every section a voter could object to
|
|
42
|
+
// is refused `objection_needs_section` — leaving yes as the only castable vote.
|
|
43
|
+
const ALL_TEMPLATE_FIELD_KEYS = [...new Set(
|
|
44
|
+
Object.keys(BUILTIN_TEMPLATES).flatMap((key) => fieldOrder(templateFor(key))),
|
|
45
|
+
)];
|
|
46
|
+
|
|
38
47
|
test('the objection section keys ARE the Full Idea template fields (no second list that drifts)', () => {
|
|
39
|
-
assert.deepEqual([...FULL_IDEA_SECTION_KEYS],
|
|
40
|
-
'board.js must flag against the
|
|
48
|
+
assert.deepEqual([...FULL_IDEA_SECTION_KEYS].sort(), [...ALL_TEMPLATE_FIELD_KEYS].sort(),
|
|
49
|
+
'board.js must flag against exactly the sections the templates declare — every vintage, no extras');
|
|
50
|
+
|
|
51
|
+
// The CHECK that is actually in force is government_003's as widened by
|
|
52
|
+
// government_017; a key is legal if the LATEST statement of the constraint
|
|
53
|
+
// carries it, so that is the file this reads.
|
|
41
54
|
const migration = fs.readFileSync(
|
|
42
|
-
path.join(GOV, 'migrations', '
|
|
55
|
+
path.join(GOV, 'migrations', 'government_017_board_section_keys_v2.sql'), 'utf8');
|
|
43
56
|
for (const key of FULL_IDEA_SECTION_KEYS) {
|
|
44
|
-
assert.ok(migration.includes(`'${key}'`), `the
|
|
57
|
+
assert.ok(migration.includes(`'${key}'`), `the in-force CHECK must carry '${key}'`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// NARROWING IS THE FAILURE MODE, so it gets its own assertion rather than
|
|
61
|
+
// riding on the set equality above: objections already cast name government_003's
|
|
62
|
+
// keys, and dropping one would orphan them and make the CHECK refuse rows that
|
|
63
|
+
// are already in the table.
|
|
64
|
+
const original = fs.readFileSync(
|
|
65
|
+
path.join(GOV, 'migrations', 'government_003_board_tables.sql'), 'utf8');
|
|
66
|
+
for (const key of fieldOrder(templateFor('base-v1'))) {
|
|
67
|
+
assert.ok(original.includes(`'${key}'`), `sanity: government_003 declared '${key}'`);
|
|
68
|
+
assert.ok(FULL_IDEA_SECTION_KEYS.includes(key), `'${key}' must never leave the list`);
|
|
69
|
+
assert.ok(migration.includes(`'${key}'`), `'${key}' must never leave the CHECK`);
|
|
45
70
|
}
|
|
46
71
|
});
|
|
47
72
|
|
|
@@ -64,6 +89,29 @@ test('a yes carries nothing; a no carries a reason AND exactly one section', ()
|
|
|
64
89
|
}
|
|
65
90
|
});
|
|
66
91
|
|
|
92
|
+
test('an objection can name a section of EITHER vintage — BV2.R06\'s actual defect', () => {
|
|
93
|
+
// The set-equality test above compares two lists. This one drives the real
|
|
94
|
+
// validator, one section at a time, because the list being right and the
|
|
95
|
+
// OBJECTION being accepted are different claims — and it is the second one
|
|
96
|
+
// that decides whether a new-vintage idea can be returned at all. Before task
|
|
97
|
+
// 1004085 every key on the base-v2 side of this loop answered
|
|
98
|
+
// `objection_needs_section`, leaving yes as the only castable vote on any idea
|
|
99
|
+
// filed after the questions changed.
|
|
100
|
+
for (const templateKey of Object.keys(BUILTIN_TEMPLATES)) {
|
|
101
|
+
for (const sectionKey of fieldOrder(templateFor(templateKey))) {
|
|
102
|
+
const v = validateVote({ direction: 'no', reasonMd: 'this section needs work', sectionKey });
|
|
103
|
+
assert.equal(v.ok, true, `${templateKey}'s '${sectionKey}' must be objectable`);
|
|
104
|
+
assert.equal(v.sectionKey, sectionKey);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// And the list is still CLOSED — widening it is not opening it. A plausible
|
|
108
|
+
// key that no template declares is refused exactly as 'vibes' is.
|
|
109
|
+
for (const notASection of ['scope_notes', 'why', 'build', 'purpose_v2']) {
|
|
110
|
+
assert.equal(validateVote({ direction: 'no', reasonMd: 'x', sectionKey: notASection }).reason,
|
|
111
|
+
'objection_needs_section', `'${notASection}' is not a declared section`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
67
115
|
// ── castVote against a stubbed db (membership logic REAL) ───────────────────
|
|
68
116
|
|
|
69
117
|
const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
|