@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.
@@ -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").
@@ -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 r = await api.tasks.getTasks({ query: { limit: 1000 } });
291
- const rows = (r.ok ? rowsOf(r.data) : null) || [];
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.638'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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: enabledDisciplines(set),
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('/government?item=11#board-room'), 'deep-links THAT item — the panel scrolls to it');
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('/government?item=11#board-room'), 'the record is still one click away');
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, '/government?item=1#board-room');
203
- // The hash is the TAB (makeTabs owns it); the item is a query param.
204
- assert.match(n.action.href, /#board-room$/);
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, '/government#board-room');
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', () => {
@@ -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