@bongos/core 1.19.638 → 1.19.640
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 +53 -33
- package/docs/adr/0275-one-source-for-a-written-role-responsibility.md +50 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +24 -23
- package/docs/copy-registry.json +39 -30
- package/docs/file-map.md +3 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/packs/artist.md +6 -2
- package/docs/packs/engineer.md +10 -0
- package/docs/packs/ideator.md +10 -0
- package/modules/builder-settings/builder-needs.js +6 -3
- package/modules/discord/board-broadcast.js +10 -3
- package/modules/grading/grader-prompt.js +14 -0
- package/modules/hall-ui/public/profile.css +17 -0
- package/modules/hall-ui/public/profile.js +25 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +4 -0
- package/scripts/gds/gen-role-responsibilities.js +134 -0
- package/scripts/gds/role-pack-guard.js +21 -0
- package/scripts/gds/sequence.js +111 -3
- package/src/module-api.js +30 -1
- package/src/modules.js +12 -1
- package/src/role-responsibilities.js +80 -0
- package/tests/discord_board_broadcast.mjs +4 -2
- package/tests/government_board_hash_redirect.mjs +60 -0
- package/tests/government_board_summons.mjs +8 -4
- package/tests/module_api.mjs +1 -0
- package/tests/role_responsibilities.mjs +187 -0
- package/tests/sequence.mjs +157 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// scripts/gds/gen-role-responsibilities.js
|
|
2
|
+
//
|
|
3
|
+
// WHAT. Writes each craft's written responsibility into its role pack under
|
|
4
|
+
// docs/packs/, between markers, from the ONE source at src/role-responsibilities.js.
|
|
5
|
+
//
|
|
6
|
+
// WHY A GENERATOR AND NOT A PARAGRAPH SOMEONE TYPES. Criterion
|
|
7
|
+
// wa6-written-responsibilities (goal 1000095) says there is one source "shown on
|
|
8
|
+
// the profile, injected into the pack, referenced by grading". The profile and the
|
|
9
|
+
// grader read the source at runtime, so they cannot drift. A markdown pack cannot
|
|
10
|
+
// require a JS module, so its copy is the only one that CAN drift — and the drift
|
|
11
|
+
// would be invisible and consequential: the pack is what a session is actually
|
|
12
|
+
// instructed by, so a stale sentence there means the project teaches one standard,
|
|
13
|
+
// shows a second on the profile, and grades against a third. This is the
|
|
14
|
+
// gen-diagrams / gen-session-index pattern (ADR 0062 §8): generate the block, gate
|
|
15
|
+
// its freshness in CI, never hand-edit it.
|
|
16
|
+
//
|
|
17
|
+
// USAGE
|
|
18
|
+
// node scripts/gds/gen-role-responsibilities.js # write the blocks
|
|
19
|
+
// node scripts/gds/gen-role-responsibilities.js --check # exit 2 if any block drifted (the CI gate)
|
|
20
|
+
//
|
|
21
|
+
// A pack with no markers is a WARNING, not a failure — a host instance may carry a
|
|
22
|
+
// pack this core does not know about. A pack whose marker block is STALE is the
|
|
23
|
+
// hard failure, because that is the drift the whole file exists to prevent.
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const { ROLE_RESPONSIBILITIES, RESPONSIBILITIES_SOURCE } = require('../../src/role-responsibilities');
|
|
30
|
+
const { resolveCoreRoot } = require('../../src/instance-config');
|
|
31
|
+
|
|
32
|
+
// The packs are CORE content (they ship in the package alongside the registry that
|
|
33
|
+
// names them), so resolve them against the core root the same way the role-pack
|
|
34
|
+
// guard does — never the instance root.
|
|
35
|
+
const CORE_ROOT = resolveCoreRoot();
|
|
36
|
+
const REGISTRY_REL = 'scripts/gds/discipline-modes.json';
|
|
37
|
+
|
|
38
|
+
const BEGIN = '<!-- BEGIN GENERATED ROLE RESPONSIBILITY (scripts/gds/gen-role-responsibilities.js — do not hand-edit) -->';
|
|
39
|
+
const END = '<!-- END GENERATED ROLE RESPONSIBILITY -->';
|
|
40
|
+
|
|
41
|
+
// The block a pack carries. Deliberately short: the pack teaches the craft, this
|
|
42
|
+
// states the standard, and the provenance line is there so a reader knows the
|
|
43
|
+
// sentence is the owner's and not the platform's opinion.
|
|
44
|
+
function blockFor(discipline) {
|
|
45
|
+
const text = ROLE_RESPONSIBILITIES[discipline];
|
|
46
|
+
if (!text) return null;
|
|
47
|
+
const s = RESPONSIBILITIES_SOURCE;
|
|
48
|
+
return [
|
|
49
|
+
BEGIN,
|
|
50
|
+
'## What this craft is answerable for',
|
|
51
|
+
'',
|
|
52
|
+
`> ${text}`,
|
|
53
|
+
'',
|
|
54
|
+
`*The ${discipline}'s written responsibility — one source (\`src/role-responsibilities.js\`), shown on the hall profile, taught here, and referenced by the grader. Fixed by ${s.authoredBy} on ${s.authoredOn}, criterion \`${s.criterion}\`. Generated: do not hand-edit, and do not paraphrase it elsewhere.*`,
|
|
55
|
+
END,
|
|
56
|
+
].join('\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Replace the marked region, or report that the pack has no markers. Mirrors
|
|
60
|
+
// gen-session-index.injectBlock: bounded by the two literal markers, so a pack may
|
|
61
|
+
// put the block anywhere and move it later without touching this file.
|
|
62
|
+
//
|
|
63
|
+
// THE BLOCK TAKES THE FILE'S OWN LINE ENDING. These packs are `.md` and check out
|
|
64
|
+
// CRLF on Windows while CI checks out LF. A generator that always wrote '\n' would
|
|
65
|
+
// splice LF lines into a CRLF file, so the committed bytes and the bytes `--check`
|
|
66
|
+
// recomputes would differ by platform — the check would be red on one checkout and
|
|
67
|
+
// green on the other, for a file nobody touched. Detecting the file's ending makes
|
|
68
|
+
// the generator idempotent on both.
|
|
69
|
+
function inject(text, block) {
|
|
70
|
+
const i = text.indexOf(BEGIN);
|
|
71
|
+
const j = text.indexOf(END);
|
|
72
|
+
if (i === -1 || j === -1 || j < i) return null;
|
|
73
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
74
|
+
return text.slice(0, i) + block.replace(/\n/g, eol) + text.slice(j + END.length);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Every discipline the role registry binds to a pack. Reading the registry rather
|
|
78
|
+
// than hardcoding three filenames means a pack that is renamed, or a fourth craft
|
|
79
|
+
// that is added later (Governor, when the owner decides), is picked up here with no
|
|
80
|
+
// edit — the same single-declaration rule the registry already enforces for claim.js.
|
|
81
|
+
function packTargets() {
|
|
82
|
+
const abs = path.join(CORE_ROOT, REGISTRY_REL);
|
|
83
|
+
const modes = (JSON.parse(fs.readFileSync(abs, 'utf8')) || {}).modes || {};
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const [discipline, mode] of Object.entries(modes)) {
|
|
86
|
+
if (!mode || !mode.pack) continue;
|
|
87
|
+
out.push({ discipline, rel: mode.pack, abs: path.join(CORE_ROOT, mode.pack) });
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function plan() {
|
|
93
|
+
const writes = [];
|
|
94
|
+
const warnings = [];
|
|
95
|
+
for (const t of packTargets()) {
|
|
96
|
+
const block = blockFor(t.discipline);
|
|
97
|
+
if (!block) {
|
|
98
|
+
// A pack whose craft has no statement is legitimate (a module-contributed
|
|
99
|
+
// craft that grew a pack). Say so; never write an empty quote into it.
|
|
100
|
+
warnings.push(`${t.rel}: no written responsibility for '${t.discipline}' — leaving the pack alone.`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
let cur;
|
|
104
|
+
try { cur = fs.readFileSync(t.abs, 'utf8'); } catch {
|
|
105
|
+
warnings.push(`${t.rel}: named by the role registry but not readable — the role-pack guard is the check that fails on this.`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const next = inject(cur, block);
|
|
109
|
+
if (next === null) { warnings.push(`${t.rel}: no marker block — add the BEGIN/END pair to carry the statement.`); continue; }
|
|
110
|
+
if (next !== cur) writes.push({ abs: t.abs, rel: t.rel, content: next });
|
|
111
|
+
}
|
|
112
|
+
return { writes, warnings };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function main() {
|
|
116
|
+
const check = process.argv.includes('--check');
|
|
117
|
+
const { writes, warnings } = plan();
|
|
118
|
+
for (const w of warnings) console.error(`gen-role-responsibilities: WARN ${w}`);
|
|
119
|
+
if (check) {
|
|
120
|
+
if (writes.length) {
|
|
121
|
+
for (const w of writes) console.error(`gen-role-responsibilities: STALE ${w.rel}`);
|
|
122
|
+
console.error('Run `node scripts/gds/gen-role-responsibilities.js` and commit.');
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
console.log('gen-role-responsibilities --check: up to date.');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
for (const w of writes) fs.writeFileSync(w.abs, w.content);
|
|
129
|
+
console.log(writes.length ? `gen-role-responsibilities: wrote ${writes.length} pack(s): ${writes.map((w) => w.rel).join(', ')}` : 'gen-role-responsibilities: already up to date.');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (require.main === module) main();
|
|
133
|
+
|
|
134
|
+
module.exports = { BEGIN, END, blockFor, inject, packTargets, plan };
|
|
@@ -101,6 +101,27 @@ function checkRolePacks(fsImpl = fs) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// Each pack's WRITTEN-RESPONSIBILITY block must match src/role-responsibilities.js
|
|
105
|
+
// (task 1003732). It lives with the other pack assertions rather than in
|
|
106
|
+
// checkGeneratedArtifactsFresh so one check owns "the packs are correct" — and the
|
|
107
|
+
// drift matters for the same reason the missing-pack case does: the profile and
|
|
108
|
+
// the grader read the source at runtime and cannot drift, but the pack is markdown,
|
|
109
|
+
// so its copy is the only one that can — and it is the copy a SESSION is instructed
|
|
110
|
+
// by. A stale block means the project teaches one standard, shows a second on the
|
|
111
|
+
// profile, and grades against a third. Skipped when the fs is stubbed (the negative
|
|
112
|
+
// tests inject a registry that names packs this generator would not find).
|
|
113
|
+
if (fsImpl === fs) {
|
|
114
|
+
try {
|
|
115
|
+
const { plan } = require('./gen-role-responsibilities.js');
|
|
116
|
+
const { writes } = plan();
|
|
117
|
+
for (const w of writes) {
|
|
118
|
+
violations.push(`${w.rel}: the written-responsibility block is STALE against src/role-responsibilities.js — run \`node scripts/gds/gen-role-responsibilities.js\` and commit (task 1003732).`);
|
|
119
|
+
}
|
|
120
|
+
} catch (e) {
|
|
121
|
+
violations.push(`could not check the packs' written-responsibility blocks (${e && e.message}) — run \`node scripts/gds/gen-role-responsibilities.js --check\`.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
104
125
|
// The split's own shape, asserted so a later edit cannot quietly drop a craft
|
|
105
126
|
// back into the kernel: three core packs, and Governor deliberately absent
|
|
106
127
|
// (criterion wa6-kernel-and-packs — "three packs, not four, until the owner decides").
|
package/scripts/gds/sequence.js
CHANGED
|
@@ -164,13 +164,62 @@ function chainFrom(headId, byId, { limit = DEFAULT_LIMIT } = {}) {
|
|
|
164
164
|
return picked;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
// Does the task's OWN declared file surface rule a schema change out? (task 1003699)
|
|
168
|
+
//
|
|
169
|
+
// tasks.needs_migration is set server-side from a keyword scan of the title and
|
|
170
|
+
// description (migration 081; detectsMigration in
|
|
171
|
+
// modules/lifecycle/routes/task-route-helpers.js), so the flag means "this task
|
|
172
|
+
// SAYS the word migration", not "this task changes the schema". Task 1003697
|
|
173
|
+
// carried it while its description said, in capitals, not to change any
|
|
174
|
+
// migration; the row for THIS task is flagged too, for the same reason. A false
|
|
175
|
+
// halt is not free — it is a task the runner will never offer.
|
|
176
|
+
//
|
|
177
|
+
// So the flag alone no longer halts. It is overridden only by positive evidence
|
|
178
|
+
// pointing the other way: a NON-EMPTY touches[] that names no path under
|
|
179
|
+
// migrations/. That is the author stating the file surface and leaving
|
|
180
|
+
// migrations/ out of it.
|
|
181
|
+
//
|
|
182
|
+
// The asymmetry is deliberate. An absent or empty touches[] tells us nothing, so
|
|
183
|
+
// it keeps halting — and it must, because the rank floor cannot be leaned on
|
|
184
|
+
// here: task 1003697 sat at requires_rank 'xenos' with the flag set, so ADR
|
|
185
|
+
// 0270's needs_migration→floor coupling did not raise it and would not have
|
|
186
|
+
// caught a real schema change either.
|
|
187
|
+
//
|
|
188
|
+
// Note this is NARROWER than what the task proposed. "touches[] hitting
|
|
189
|
+
// migrations/, or a declared migration number" would have re-read the prose, and
|
|
190
|
+
// 1003697's description contains a literal migrations/… path while its touches[]
|
|
191
|
+
// contains none — so the number/path branch would have preserved the very false
|
|
192
|
+
// halt the task was filed to remove, and failed its own acceptance criterion.
|
|
193
|
+
// touches[] is the only signal here that is structural rather than vocabulary.
|
|
194
|
+
// A `migrations` DIRECTORY anywhere in the path, not only at the repo root.
|
|
195
|
+
// This codebase ships MODULE-OWNED schema migrations at
|
|
196
|
+
// modules/<key>/migrations/NNN_*.sql (ADR 0083; docs/modules-contract.md §
|
|
197
|
+
// "Module-owned migrations"), and ten such directories exist today — so an
|
|
198
|
+
// anchored /^migrations\// test would have called every one of them "not a
|
|
199
|
+
// migration" and suppressed the halt on a real schema change.
|
|
200
|
+
//
|
|
201
|
+
// That mistake would have been worse than the bug this task fixes, because
|
|
202
|
+
// nothing else catches it: permission-path-check's protected-surfaces registry
|
|
203
|
+
// carries a `migrations/` glob that IS a second gate for root migrations, but it
|
|
204
|
+
// is root-anchored too, so a module-owned migration has no other halt at all.
|
|
205
|
+
//
|
|
206
|
+
// Matching a whole path SEGMENT is what keeps docs/migrations-guide.md — a file
|
|
207
|
+
// with the word in its name and no migrations directory — from counting.
|
|
208
|
+
const MIGRATION_DIR_RE = /(?:^|\/)migrations\//i;
|
|
209
|
+
|
|
210
|
+
function declaredSurfaceExcludesMigrations(task) {
|
|
211
|
+
const touches = Array.isArray(task.touches) ? task.touches : [];
|
|
212
|
+
if (!touches.length) return false;
|
|
213
|
+
return !touches.some((raw) => MIGRATION_DIR_RE.test(String(raw).trim().replace(/\\/g, '/')));
|
|
214
|
+
}
|
|
215
|
+
|
|
167
216
|
// The halt matrix. Facts only — every entry is something the runner can read
|
|
168
217
|
// off the task row, never a judgement about the work itself.
|
|
169
218
|
function haltReasons(task, { rank, claimableIds = null } = {}) {
|
|
170
219
|
const reasons = [];
|
|
171
220
|
if (!task) return [{ code: 'not_found', detail: 'no such task' }];
|
|
172
221
|
|
|
173
|
-
if (task.needs_migration) {
|
|
222
|
+
if (task.needs_migration && !declaredSurfaceExcludesMigrations(task)) {
|
|
174
223
|
reasons.push({ code: 'needs_migration', detail: HALT_REASONS.needs_migration });
|
|
175
224
|
}
|
|
176
225
|
if (task.security_sensitive) {
|
|
@@ -286,9 +335,64 @@ async function fetchChain(api, headId, limit) {
|
|
|
286
335
|
return byId;
|
|
287
336
|
}
|
|
288
337
|
|
|
338
|
+
// GOAL_PAGE is deliberately below the route's limit cap (1000): the cap is what
|
|
339
|
+
// made the old single read look complete, and paging in smaller bites means the
|
|
340
|
+
// "did I get everything?" question is answered by a short last page rather than
|
|
341
|
+
// by trusting one number. GOAL_MAX_ROWS is a runaway guard, not a window — it is
|
|
342
|
+
// far above any real goal, and hitting it throws rather than truncating.
|
|
343
|
+
const GOAL_PAGE = 500;
|
|
344
|
+
const GOAL_MAX_ROWS = 20000;
|
|
345
|
+
|
|
346
|
+
// Every task row in one goal, paged until the server runs out (task 1003699).
|
|
347
|
+
//
|
|
348
|
+
// THE BUG THIS REPLACES. The old body asked for `?limit=1000` with NO goal
|
|
349
|
+
// filter and matched goal_id in JS. Past a thousand rows in that ordering — this
|
|
350
|
+
// instance crossed it long ago — a task in the goal simply fell outside the
|
|
351
|
+
// window, and the planner printed "No open tasks in goal N". That is
|
|
352
|
+
// indistinguishable from a finished chain, which is the dangerous part: the
|
|
353
|
+
// runner concludes the goal is DONE and stops. Raising the limit only moves the
|
|
354
|
+
// cliff, so the fix is to filter server-side (?goal_id=, ADR 0086 §3) and page.
|
|
355
|
+
//
|
|
356
|
+
// ANY unreadable page throws, for the same reason the bug was invisible: "I
|
|
357
|
+
// could not look" and "there is nothing left" must never render as the same
|
|
358
|
+
// sentence — and a dropped middle page would say the second while meaning the
|
|
359
|
+
// first.
|
|
360
|
+
async function fetchGoalTaskRows(api, goalId) {
|
|
361
|
+
const rows = [];
|
|
362
|
+
for (let offset = 0; ; offset += GOAL_PAGE) {
|
|
363
|
+
const query = { goal_id: Number(goalId), limit: GOAL_PAGE };
|
|
364
|
+
// The route rejects offset=0 as "must be a positive integer", so page one
|
|
365
|
+
// sends no offset at all.
|
|
366
|
+
if (offset) query.offset = offset;
|
|
367
|
+
const r = await api.tasks.getTasks({ query });
|
|
368
|
+
if (!r.ok) {
|
|
369
|
+
// ANY failed page throws, not just the first. Returning the rows read so
|
|
370
|
+
// far would hand back a short list that looks like a complete goal — the
|
|
371
|
+
// same "silent incompleteness reads as done" mistake this task exists to
|
|
372
|
+
// remove, just relocated from a fixed window to a transient failure. A
|
|
373
|
+
// caller that wants partial data can ask again; one that is told nothing
|
|
374
|
+
// cannot know to.
|
|
375
|
+
throw new Error(
|
|
376
|
+
`could not read the tasks in goal ${goalId} at offset ${offset} `
|
|
377
|
+
+ `(HTTP ${r.status || '?'}) — refusing to report a partial goal as a complete one`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const page = rowsOf(r.data) || [];
|
|
381
|
+
rows.push(...page);
|
|
382
|
+
if (page.length < GOAL_PAGE) break;
|
|
383
|
+
if (rows.length >= GOAL_MAX_ROWS) {
|
|
384
|
+
throw new Error(`goal ${goalId} returned ${rows.length}+ rows — refusing to page further`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return rows;
|
|
388
|
+
}
|
|
389
|
+
|
|
289
390
|
async function fetchGoalTasks(api, goalId, limit) {
|
|
290
|
-
const
|
|
291
|
-
|
|
391
|
+
const rows = await fetchGoalTaskRows(api, goalId);
|
|
392
|
+
// The goal_id match stays even though the server filtered: a core pinned older
|
|
393
|
+
// than ADR 0086 §3 ignores an unknown query param instead of rejecting it, so
|
|
394
|
+
// without this line an un-upgraded instance would silently hand back the whole
|
|
395
|
+
// table and the planner would plan someone else's goal.
|
|
292
396
|
const mine = rows
|
|
293
397
|
.filter((t) => Number(t.goal_id) === Number(goalId) && !TERMINAL.has(t.status))
|
|
294
398
|
.slice(0, limit);
|
|
@@ -472,6 +576,10 @@ module.exports = {
|
|
|
472
576
|
readArgv,
|
|
473
577
|
edgeIds,
|
|
474
578
|
rowsOf,
|
|
579
|
+
fetchGoalTasks,
|
|
580
|
+
fetchGoalTaskRows,
|
|
581
|
+
declaredSurfaceExcludesMigrations,
|
|
582
|
+
GOAL_PAGE,
|
|
475
583
|
topoOrder,
|
|
476
584
|
chainFrom,
|
|
477
585
|
haltReasons,
|
package/src/module-api.js
CHANGED
|
@@ -41,6 +41,22 @@ const instanceConfig = require('./instance-config');
|
|
|
41
41
|
const staleTimer = require('./stale-timer');
|
|
42
42
|
const seams = require('./module-seams');
|
|
43
43
|
const { buildInfo } = require('./build-info');
|
|
44
|
+
// Eager and DESTRUCTURED on purpose, and inside the rule above rather than an
|
|
45
|
+
// exception to it: role-responsibilities.js requires NOTHING (80 lines of the
|
|
46
|
+
// owner's text plus three pure lookups), so it costs one file read and drags no
|
|
47
|
+
// server file into a CLI subcommand's require-closure — the same footing as
|
|
48
|
+
// `branding` and `instanceConfig` here.
|
|
49
|
+
//
|
|
50
|
+
// It has to be a NAMED require rather than `require(...).x` read inside a getter,
|
|
51
|
+
// or CI's dead-code ratchet counts these exports as dead and no PR touching them
|
|
52
|
+
// can merge — task 1003732 stranded on exactly that. `responsibilityFor`'s only
|
|
53
|
+
// consumer is modules/grading/grader-prompt.js reaching through this doorway, and
|
|
54
|
+
// modules/ sits outside knip's `project` globs by design, so the import HERE is
|
|
55
|
+
// the only place that use can be seen. knip credits a destructured binding and
|
|
56
|
+
// not a member access on a namespace object, which is why the sibling exports
|
|
57
|
+
// (`responsibilitiesFor` via src/modules.js, `ROLE_RESPONSIBILITIES` via
|
|
58
|
+
// scripts/gds/gen-role-responsibilities.js) were never flagged and this one was.
|
|
59
|
+
const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibilities');
|
|
44
60
|
|
|
45
61
|
// ---------------------------------------------------------------------------
|
|
46
62
|
// CORE_VERSION — the version of THIS published surface (semver).
|
|
@@ -55,7 +71,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
73
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.640'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
75
|
|
|
60
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -405,6 +421,19 @@ module.exports = {
|
|
|
405
421
|
get pageMeta() { return require('./bongos/routes/_helpers').pageMeta; },
|
|
406
422
|
get PAGINATION() { return require('./bongos/routes/_helpers').PAGINATION; },
|
|
407
423
|
|
|
424
|
+
// --- the written role responsibilities (task 1003732, criterion
|
|
425
|
+
// wa6-written-responsibilities). ONE source for the sentence each craft is
|
|
426
|
+
// answerable for; it reaches modules through the doorway because more than
|
|
427
|
+
// one module needs the same text — grading references it when judging
|
|
428
|
+
// role-shaped work, and the hall shows it to the person being judged — and
|
|
429
|
+
// modules never import each other. `responsibilityFor(discipline)` returns
|
|
430
|
+
// null for a craft with no statement (a module-contributed one, or an
|
|
431
|
+
// unclassified task); that is a legitimate answer, not an error.
|
|
432
|
+
// Plain entries rather than getters: the module is loaded eagerly at the
|
|
433
|
+
// top of this file (it costs nothing), so there is nothing left to defer.
|
|
434
|
+
responsibilityFor,
|
|
435
|
+
ROLE_RESPONSIBILITIES,
|
|
436
|
+
|
|
408
437
|
// --- logging
|
|
409
438
|
logger,
|
|
410
439
|
};
|
package/src/modules.js
CHANGED
|
@@ -28,6 +28,7 @@ const path = require('node:path');
|
|
|
28
28
|
// until a module is moved into modules/ — so this is a no-op on the current tree.
|
|
29
29
|
const loader = require('./module-loader/loader');
|
|
30
30
|
const { resolveCoreRoot, resolveInstanceRoot } = require('./instance-config');
|
|
31
|
+
const { responsibilitiesFor } = require('./role-responsibilities');
|
|
31
32
|
|
|
32
33
|
// ADR 0108 §1: the neutral starter ships WITH the core package; the instance
|
|
33
34
|
// pack is host content. Both resolvers return the repo root today, so this is
|
|
@@ -250,10 +251,20 @@ function enabledDisciplines(set = modules()) {
|
|
|
250
251
|
|
|
251
252
|
// CLIENT-SAFE projection — module flags are just booleans, all safe to expose.
|
|
252
253
|
// The hall/status front-ends read this to hide UI sections for off modules.
|
|
254
|
+
//
|
|
255
|
+
// `responsibilities` rides here (task 1003732) because this projection is already
|
|
256
|
+
// injected into every served page as a global (serve-internal.injectBrandingGlobal),
|
|
257
|
+
// and it already carries the discipline roster the statements are keyed by — so the
|
|
258
|
+
// profile gets the owner's text with no new route, no fetch, and no second copy to
|
|
259
|
+
// drift. Scoped to the disciplines this instance actually offers: a craft the
|
|
260
|
+
// instance does not have must not appear in its UI, and a module-contributed craft
|
|
261
|
+
// has no statement by design (src/role-responsibilities.js says why).
|
|
253
262
|
function clientModules(set = modules()) {
|
|
263
|
+
const disciplines = enabledDisciplines(set);
|
|
254
264
|
return {
|
|
255
265
|
enabled: knownModules().reduce((o, k) => { o[k] = set[k] === true; return o; }, {}),
|
|
256
|
-
disciplines
|
|
266
|
+
disciplines,
|
|
267
|
+
responsibilities: responsibilitiesFor(disciplines),
|
|
257
268
|
};
|
|
258
269
|
}
|
|
259
270
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// src/role-responsibilities.js
|
|
2
|
+
//
|
|
3
|
+
// WHAT. The one source of each craft's written responsibility — the sentence that
|
|
4
|
+
// says what a person holding that role is actually answerable for. Three consumers
|
|
5
|
+
// read this file and nothing else: the hall profile shows it to the person, the
|
|
6
|
+
// role pack under docs/packs/ teaches a session from it, and the grader references
|
|
7
|
+
// it when it judges role-shaped work.
|
|
8
|
+
//
|
|
9
|
+
// WHY IT IS ONE FILE. Criterion wa6-written-responsibilities (goal 1000095) is
|
|
10
|
+
// explicit that there is ONE source — "shown on the profile, injected into the
|
|
11
|
+
// pack, referenced by grading" — and the reason is not tidiness. The promise made
|
|
12
|
+
// to a builder on their profile and the text their session is actually held to
|
|
13
|
+
// must be the same text; two copies that drift mean the project judged someone
|
|
14
|
+
// against a standard it never showed them. The pack half is enforced rather than
|
|
15
|
+
// promised: the packs carry a GENERATED block written by
|
|
16
|
+
// scripts/gds/gen-role-responsibilities.js, and fitness.js fails CI when it drifts
|
|
17
|
+
// from this file (the gen-diagrams / gen-session-index pattern, ADR 0062 §8).
|
|
18
|
+
//
|
|
19
|
+
// THE TEXT IS THE OWNER'S, VERBATIM. Fixed by the area owner on 2026-09-08 and
|
|
20
|
+
// recorded in the criterion. Do not paraphrase, tighten, or "improve" these
|
|
21
|
+
// sentences — a role's responsibility is the owner's to write, and the whole point
|
|
22
|
+
// of a single source is that it says what they said. Per-project sub-specialities
|
|
23
|
+
// (idea 1000733) build on this later and are deliberately not modelled here.
|
|
24
|
+
//
|
|
25
|
+
// KERNEL FILE. On the fitness.js KERNEL_FILES roster: three different modules read
|
|
26
|
+
// it (hall-ui, grading) plus the core's own client projection, and modules never
|
|
27
|
+
// import each other, so shared vocabulary has to sit in the kernel. It imports
|
|
28
|
+
// nothing, which is what makes that placement free.
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
// discipline key (tasks.discipline / builders.preferred_disciplines) → the owner's
|
|
32
|
+
// sentence. Keys mirror CORE_DISCIPLINES order (engineer | artist | ideator), the
|
|
33
|
+
// migration 176 enum order. A module-contributed discipline (ui-design → 'ui') has
|
|
34
|
+
// no statement here on purpose: a craft a module brings is that module's to
|
|
35
|
+
// describe, and inventing one for it would be exactly the paraphrase this file
|
|
36
|
+
// forbids. Consumers must therefore treat "no statement" as normal, not as an error.
|
|
37
|
+
const ROLE_RESPONSIBILITIES = Object.freeze({
|
|
38
|
+
engineer: 'Running and optimizing the running of Claude nonstop, and ensuring Ideators and Artists can continue to interface with that system effectively.',
|
|
39
|
+
artist: 'No slop in the appearance and text of the project; the story, emotion and ideology of the project are communicated effectively; project purpose and gravitas are upheld.',
|
|
40
|
+
ideator: 'Make good ideas; be a philosopher / thought leader for the project; enable Engineers to scope and Artists to create with maximum efficiency — a baseline for creating scopes of work.',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Provenance, carried with the text so every rendering can say where it came from
|
|
44
|
+
// rather than looking like a platform opinion someone typed.
|
|
45
|
+
const RESPONSIBILITIES_SOURCE = Object.freeze({
|
|
46
|
+
criterion: 'wa6-written-responsibilities',
|
|
47
|
+
goalId: '1000095',
|
|
48
|
+
authoredOn: '2026-09-08',
|
|
49
|
+
authoredBy: 'the area owner',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// The statement for one discipline, or null when the craft has none (a
|
|
53
|
+
// module-contributed discipline, an unclassified task, a stray value from an older
|
|
54
|
+
// row). Null is a legitimate answer every consumer must render as absence — never
|
|
55
|
+
// as a blank quote or a placeholder sentence.
|
|
56
|
+
function responsibilityFor(discipline) {
|
|
57
|
+
if (typeof discipline !== 'string') return null;
|
|
58
|
+
return ROLE_RESPONSIBILITIES[discipline.toLowerCase()] || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The subset of statements an instance actually offers, so a client projection
|
|
62
|
+
// never ships a craft the instance does not have. Takes the discipline roster the
|
|
63
|
+
// caller already resolved (modules.enabledDisciplines()) rather than reaching for
|
|
64
|
+
// the module registry itself — that would make this file import domain and cost it
|
|
65
|
+
// its place on the kernel roster.
|
|
66
|
+
function responsibilitiesFor(disciplines) {
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const d of Array.isArray(disciplines) ? disciplines : []) {
|
|
69
|
+
const text = responsibilityFor(d);
|
|
70
|
+
if (text) out[d] = text;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
ROLE_RESPONSIBILITIES,
|
|
77
|
+
RESPONSIBILITIES_SOURCE,
|
|
78
|
+
responsibilityFor,
|
|
79
|
+
responsibilitiesFor,
|
|
80
|
+
};
|
|
@@ -50,7 +50,8 @@ test('linked members are @mentioned on the allowlist; unlinked members are named
|
|
|
50
50
|
assert.ok(posted.text.includes('<@111222333>'));
|
|
51
51
|
assert.ok(posted.text.includes('unlinked-two'), 'the unlinked member is still named');
|
|
52
52
|
assert.ok(posted.text.includes('within 90 minutes'), 'the mention IS the vote-within-X notice');
|
|
53
|
-
assert.ok(posted.text.includes('/
|
|
53
|
+
assert.ok(posted.text.includes('/board-room?item=11'), 'deep-links THAT item — the page scrolls to it');
|
|
54
|
+
assert.ok(!posted.text.includes('#board-room'), 'never emits the retired hash form (ADR 0266)');
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
test('a clockless sitting says so, and an amendment names its subject honestly', async () => {
|
|
@@ -221,7 +222,8 @@ test('the close is EDITED onto the same message, and the edit is stamped', async
|
|
|
221
222
|
assert.equal(edited.messageId, '555000111');
|
|
222
223
|
assert.ok(edited.text.includes('RATIFIED'), 'the summons now reads as the outcome');
|
|
223
224
|
assert.ok(!edited.text.includes('your vote is wanted'), 'nothing stale survives the edit');
|
|
224
|
-
assert.ok(edited.text.includes('/
|
|
225
|
+
assert.ok(edited.text.includes('/board-room?item=11'), 'the record is still one click away');
|
|
226
|
+
assert.ok(!edited.text.includes('#board-room'), 'never emits the retired hash form (ADR 0266)');
|
|
225
227
|
assert.equal(stamped, '11', 'edited_at answers "is this window still shown as open?" without asking Discord');
|
|
226
228
|
});
|
|
227
229
|
|
|
@@ -127,3 +127,63 @@ test('the government page no longer declares a board-room tab to redirect INTO',
|
|
|
127
127
|
const ids = [...GOV_SRC.matchAll(/\{ id: '([a-z-]+)', label: '[^']*', panel:/g)].map((m) => m[1]);
|
|
128
128
|
assert.ok(!ids.includes('board-room'), `board-room is a page, not a tab (found ${ids})`);
|
|
129
129
|
});
|
|
130
|
+
|
|
131
|
+
// ── the redirect is the ONLY thing that knows the old shape (task 1003736) ────
|
|
132
|
+
//
|
|
133
|
+
// The carve moved the room; task 1003736 moved the WRITERS. Its done-when clause
|
|
134
|
+
// is a statement about the whole tree — "nothing outside the government.js
|
|
135
|
+
// compatibility redirect emits the hash form" — and a claim of that shape rots
|
|
136
|
+
// the moment someone adds a fourth writer by copying an older one. Grepping once
|
|
137
|
+
// at review time proves it for that afternoon only, so it is pinned here instead.
|
|
138
|
+
//
|
|
139
|
+
// TWO TRAPS THIS ENCODES, both of which make the naive sweep wrong:
|
|
140
|
+
// 1. `#board-room` is ALSO the name of a Discord CHANNEL (channels.json, BV1
|
|
141
|
+
// R20). modules/government/CLAUDE.md and the BV1 spec name that channel, not
|
|
142
|
+
// a URL — repointing them would be a straightforward mistake, so the sweep
|
|
143
|
+
// hunts the URL form specifically and the quoted literal separately.
|
|
144
|
+
// 2. ADRs and session logs RECORD the old form on purpose; they are history and
|
|
145
|
+
// may not be rewritten to match today's code.
|
|
146
|
+
const SWEEP_DIRS = ['modules', 'src', 'scripts', 'docs/recipes', 'docs/packs', '.claude/skills'];
|
|
147
|
+
const SWEEP_EXTS = new Set(['.js', '.mjs', '.cjs', '.html', '.css', '.json', '.md']);
|
|
148
|
+
|
|
149
|
+
function sweepFiles() {
|
|
150
|
+
const out = [];
|
|
151
|
+
const walk = (rel) => {
|
|
152
|
+
const abs = path.join(ROOT, rel);
|
|
153
|
+
if (!fs.existsSync(abs)) return;
|
|
154
|
+
for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
155
|
+
if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;
|
|
156
|
+
const childRel = `${rel}/${e.name}`;
|
|
157
|
+
if (e.isDirectory()) walk(childRel);
|
|
158
|
+
else if (SWEEP_EXTS.has(path.extname(e.name))) out.push(childRel);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
for (const d of SWEEP_DIRS) walk(d);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
test('no writer anywhere in the tree still emits the retired hash URL', () => {
|
|
166
|
+
// The emitted form, not the bare fragment: `/government…#board-room`. This is
|
|
167
|
+
// what a link WRITER produces, and it is the thing task 1003736 repointed in
|
|
168
|
+
// builder-needs.js (hall banner + CLI nudge) and board-broadcast.js (Discord).
|
|
169
|
+
const offenders = sweepFiles().filter((rel) =>
|
|
170
|
+
/\/government[^\s'"`)\]]*#board-room/.test(fs.readFileSync(path.join(ROOT, rel), 'utf8')));
|
|
171
|
+
assert.deepEqual(offenders, [],
|
|
172
|
+
`these still emit the retired deep link — point them at /board-room?item=<id>:\n ${offenders.join('\n ')}`);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('the hash literal survives only in the redirect that consumes it', () => {
|
|
176
|
+
// The QUOTED literal is the runtime value — the thing a branch compares
|
|
177
|
+
// against. Comments discussing the retired tab are documentation and are left
|
|
178
|
+
// alone (they use backticks, not quotes), which is why this looks for quotes.
|
|
179
|
+
const allowed = 'modules/hall-ui/public/government.js';
|
|
180
|
+
const offenders = sweepFiles().filter((rel) => rel !== allowed
|
|
181
|
+
&& /['"]#board-room['"]/.test(fs.readFileSync(path.join(ROOT, rel), 'utf8')));
|
|
182
|
+
assert.deepEqual(offenders, [],
|
|
183
|
+
`only ${allowed}'s redirect may still know the old shape, found in:\n ${offenders.join('\n ')}`);
|
|
184
|
+
|
|
185
|
+
// And the allowlisted file must actually still contain it — an allowlist that
|
|
186
|
+
// guards a string nobody has any more is a rule quietly protecting nothing.
|
|
187
|
+
assert.match(GOV_SRC, /['"]#board-room['"]/,
|
|
188
|
+
'the compatibility redirect itself is gone — old Discord links now dead-end');
|
|
189
|
+
});
|
|
@@ -199,14 +199,18 @@ test('pending votes are action_needed and deep-link the oldest sitting', () => {
|
|
|
199
199
|
const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 9, first_item_id: '1' } });
|
|
200
200
|
assert.equal(n.id, 'board_votes');
|
|
201
201
|
assert.equal(n.state, 'action_needed');
|
|
202
|
-
assert.equal(n.action.href, '/
|
|
203
|
-
// The
|
|
204
|
-
|
|
202
|
+
assert.equal(n.action.href, '/board-room?item=1');
|
|
203
|
+
// The item is a query param, because `/board-room` reads `?item=` and a
|
|
204
|
+
// FRAGMENT would never reach the server (ADR 0266). The old
|
|
205
|
+
// `/government…#board-room` form survives only as government.js's
|
|
206
|
+
// compatibility redirect — no writer may emit it.
|
|
207
|
+
assert.ok(!n.action.href.includes('#board-room'), 'never emits the retired hash form');
|
|
205
208
|
});
|
|
206
209
|
|
|
207
210
|
test('a count with no item id still links the room', () => {
|
|
208
211
|
const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 2 } });
|
|
209
|
-
assert.equal(n.action.href, '/
|
|
212
|
+
assert.equal(n.action.href, '/board-room');
|
|
213
|
+
assert.ok(!n.action.href.includes('#board-room'), 'never emits the retired hash form');
|
|
210
214
|
});
|
|
211
215
|
|
|
212
216
|
test('the need reaches computeNeeds as an action_needed item', () => {
|
package/tests/module_api.mjs
CHANGED
|
@@ -46,6 +46,7 @@ const PUBLISHED_SURFACE = [
|
|
|
46
46
|
'contribute', 'contributions', 'collectContributions', 'listContributionPoints',
|
|
47
47
|
'buildInfo', 'validateOrRespond', 'LIMITS', 'parseId', 'asyncHandler', 'corsPublicGet', // parseId added 1.8.0 (BV1.R78); asyncHandler + corsPublicGet added 1.12.0 (BV1.R86; lifecycle routes)
|
|
48
48
|
'parsePagination', 'pageMeta', 'PAGINATION', // R14 (#2001, 1.17.0): shared pagination contract for module list routes (ADR 0119)
|
|
49
|
+
'responsibilityFor', 'ROLE_RESPONSIBILITIES', // added by task 1003732 (ADR 0275; criterion wa6-written-responsibilities): the ONE source for the sentence each craft is answerable for. It rides the doorway because two modules need the same text — grading judges role-shaped work against it, the hall shows it to the person being judged — and modules never import each other. Declared here deliberately: this list is what makes adding to the doorway a decision rather than a side effect
|
|
49
50
|
'logger',
|
|
50
51
|
];
|
|
51
52
|
|