@bongos/core 1.19.842 → 1.19.844

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.
Files changed (40) hide show
  1. package/.bongos-core.json +70 -40
  2. package/docs/module-api-changelog.md +4 -0
  3. package/migrations/core_247_discipline_required.sql +58 -0
  4. package/modules/agents/lib/spend-ceiling.js +126 -37
  5. package/modules/agents/migrations/agents_004_spend_cap.sql +53 -0
  6. package/modules/agents/pollers/event-router.js +29 -2
  7. package/modules/agents/routes/agents.js +38 -1
  8. package/modules/agents/spawn.js +73 -1
  9. package/modules/copy-desk/routes/copy-desk.js +6 -0
  10. package/modules/economy/cost-classify.js +11 -0
  11. package/modules/economy/cost.js +21 -3
  12. package/modules/economy/reward.js +7 -0
  13. package/modules/hall-ui/public/goals.states.json +2 -2
  14. package/modules/hall-ui/public/idea.states.json +1 -1
  15. package/modules/hall-ui/public/ideas.states.json +1 -1
  16. package/modules/hall-ui/public/index.states.json +1 -1
  17. package/modules/hall-ui/public/pair.states.json +1 -1
  18. package/modules/hall-ui/public/settings.states.json +1 -1
  19. package/modules/hall-ui/public/task.states.json +1 -1
  20. package/modules/hall-ui/public/work.states.json +1 -1
  21. package/modules/ideas/inbox.js +4 -2
  22. package/modules/lifecycle/db-tasks.js +28 -2
  23. package/modules/lifecycle/lifecycle.js +6 -0
  24. package/modules/lifecycle/routes/goals.js +17 -4
  25. package/modules/lifecycle/routes/task-write-routes.js +25 -6
  26. package/modules/lifecycle/task-classifier.js +39 -0
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/scripts/gds/backfill-disciplines.js +144 -0
  30. package/scripts/gds/fitness-ratchets.js +59 -2
  31. package/scripts/gds/init.js +6 -1
  32. package/scripts/gds/triage.js +7 -0
  33. package/src/module-api.js +1 -1
  34. package/tests/agents_spend_guard.mjs +203 -2
  35. package/tests/hall_states_regex_escapes.mjs +98 -0
  36. package/tests/knip_ratchet_ambient.mjs +101 -0
  37. package/tests/rank_floor_wiring.mjs +4 -0
  38. package/tests/reward_gate.mjs +4 -4
  39. package/tests/task_discipline_required.mjs +163 -0
  40. package/tests/task_goal_required.mjs +4 -4
@@ -0,0 +1,98 @@
1
+ // tests/hall_states_regex_escapes.mjs — a states file's ignore lists are REGEX
2
+ // SOURCES, so a JSON escape that produces a control character is a silently dead
3
+ // pattern (task 1003638).
4
+ //
5
+ // THE DEFECT. `modules/ui-design/kit/lib.js` does:
6
+ //
7
+ // const con = ignoreConsole.map((s) => new RegExp(s));
8
+ //
9
+ // so every entry is a regex source. In JSON, `"\b"` is the escape for BACKSPACE
10
+ // (U+0008) — not the two characters a regex needs for a word boundary. Eight states
11
+ // files wrote `"\b401\b"` where they meant `"\\b401\\b"`, compiling a pattern that
12
+ // matches a literal backspace beside "401". Console text never contains one, so the
13
+ // declared noise suppression did nothing and nothing ever said so.
14
+ //
15
+ // MEASURED across modules/hall-ui/public/*.states.json on 2026-09-17: 8 files, 17
16
+ // entries — goals, idea, ideas, index, pair, settings, task, work. (The row that
17
+ // filed this named only `work`, and listed `task`, `idea` and `ideas` among the
18
+ // HEALTHY files; all three were broken. The fix followed the computed list.)
19
+ //
20
+ // Run: node --test tests/hall_states_regex_escapes.mjs
21
+
22
+ import { test } from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+ import { readFileSync, readdirSync } from 'node:fs';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const DIR = path.join(REPO_ROOT, 'modules/hall-ui/public');
30
+ const REGEX_LISTS = ['ignoreConsole', 'ignoreRequests', 'ignoreControls'];
31
+ const CONTROL = /[\x00-\x1f]/;
32
+
33
+ const files = readdirSync(DIR).filter((n) => n.endsWith('.states.json'));
34
+
35
+ test('there are states files to check — an empty sweep must not read as a pass', () => {
36
+ assert.ok(files.length > 10, `expected the hall states files, found ${files.length}`);
37
+ });
38
+
39
+ test('no ignore-list entry contains a control character', () => {
40
+ const offenders = [];
41
+ for (const name of files) {
42
+ const json = JSON.parse(readFileSync(path.join(DIR, name), 'utf8'));
43
+ for (const key of REGEX_LISTS) {
44
+ if (!Array.isArray(json[key])) continue;
45
+ for (const entry of json[key]) {
46
+ if (CONTROL.test(entry)) {
47
+ const codes = [...entry].map((c) => c.charCodeAt(0)).filter((n) => n < 32)
48
+ .map((n) => `U+${n.toString(16).padStart(4, '0')}`).join(',');
49
+ offenders.push(`${name} ${key}: ${JSON.stringify(entry)} (${codes})`);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ assert.deepEqual(offenders, [],
55
+ `a JSON escape produced a control character in a regex source — almost certainly \\b meant as a word boundary:\n ${offenders.join('\n ')}`);
56
+ });
57
+
58
+ test('every ignore-list entry compiles as a regex', () => {
59
+ // lib.js calls new RegExp(s) with no try/catch, so an uncompilable entry is a
60
+ // crash in the walk rather than a dead pattern.
61
+ const bad = [];
62
+ for (const name of files) {
63
+ const json = JSON.parse(readFileSync(path.join(DIR, name), 'utf8'));
64
+ for (const key of REGEX_LISTS) {
65
+ if (!Array.isArray(json[key])) continue;
66
+ for (const entry of json[key]) {
67
+ try { new RegExp(entry); } catch (e) { bad.push(`${name} ${key}: ${JSON.stringify(entry)} — ${e.message}`); }
68
+ }
69
+ }
70
+ }
71
+ assert.deepEqual(bad, [], `uncompilable regex source:\n ${bad.join('\n ')}`);
72
+ });
73
+
74
+ test('the word-boundary patterns actually match a real console line', () => {
75
+ // The behavioural half: shape alone would still pass if someone "fixed" the control
76
+ // character by deleting it (leaving "401", which over-matches, e.g. "1401ms").
77
+ const line = 'Failed to load resource: the server responded with a status of 404 (Not Found)';
78
+ const work = JSON.parse(readFileSync(path.join(DIR, 'work.states.json'), 'utf8'));
79
+ const matched = work.ignoreConsole.some((s) => new RegExp(s).test(line));
80
+ assert.equal(matched, true, 'the declared 404 suppression must match a real 404 console line');
81
+
82
+ // And the boundary must still be a boundary — not a bare number.
83
+ const boundary = work.ignoreConsole.find((s) => s.includes('404'));
84
+ assert.ok(boundary, 'a 404 entry must exist');
85
+ assert.equal(new RegExp(boundary).test('elapsed 1404ms'), false,
86
+ 'the pattern must not match a 404 embedded in a longer number — that is what the word boundary is for');
87
+ });
88
+
89
+ test('the broken form is genuinely inert — this is what the bug cost', () => {
90
+ // Pins WHY it was invisible: the old value silently matched nothing rather than
91
+ // erroring, so every gate stayed green while the suppression did nothing.
92
+ const broken = '\x08404\x08';
93
+ const line = 'Failed to load resource: the server responded with a status of 404 (Not Found)';
94
+ assert.equal(new RegExp(broken).test(line), false,
95
+ 'the backspace form matches nothing — silent, which is why it survived');
96
+ assert.doesNotThrow(() => new RegExp(broken),
97
+ 'and it compiles, so nothing ever complained');
98
+ });
@@ -0,0 +1,101 @@
1
+ // tests/knip_ratchet_ambient.mjs — the knip ratchet refuses to score through an
2
+ // ambient knip, instead of failing CLOSED on a number it cannot compare (task 1003987).
3
+ //
4
+ // THE DEFECT. `npx --no-install` walks UP the directory tree. A git worktree has no
5
+ // node_modules of its own, so the lookup climbs out to the main checkout and succeeds —
6
+ // and knip, unable to resolve itself as a declared dependency of the tree it is
7
+ // scanning, reports ITSELF as an unlisted binary.
8
+ //
9
+ // MEASURED on one tree, 2026-09-17:
10
+ // main checkout (knip local) -> 221, PASS
11
+ // worktree (knip ambient) -> 222, FAIL against baseline 221
12
+ // The extra was `binaries: package.json knip`. Diffed BY NAME the exports lists were
13
+ // identical — 221 both sides. Nothing about the code had moved.
14
+ //
15
+ // That false red cost a full elimination pass on task 1003848: the count was the
16
+ // loudest signal on the board and it pointed away from the real defect.
17
+ //
18
+ // The module's contract is fail-OPEN on infrastructure, fail-CLOSED on findings. A
19
+ // count taken through an ambient knip is an infrastructure artefact, so it must not be
20
+ // scored. CI is unaffected — `npm ci` puts knip in the project's own node_modules.
21
+ //
22
+ // Run: node --test tests/knip_ratchet_ambient.mjs
23
+
24
+ import { test } from 'node:test';
25
+ import assert from 'node:assert/strict';
26
+ import fs from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+ import { readFileSync } from 'node:fs';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { createRequire } from 'node:module';
32
+
33
+ const require = createRequire(import.meta.url);
34
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
35
+ const ratchets = require('../scripts/gds/fitness-ratchets.js');
36
+ const SRC = readFileSync(path.join(REPO_ROOT, 'scripts/gds/fitness-ratchets.js'), 'utf8');
37
+
38
+ test('the local-knip check runs BEFORE npx, so an ambient knip is never scored', () => {
39
+ const fn = SRC.slice(SRC.indexOf('function knipIssueCount()'));
40
+ const body = fn.slice(0, fn.indexOf('\n}\n'));
41
+ const guard = body.indexOf('localKnipPath()');
42
+ const spawn = body.indexOf('execSync(');
43
+ assert.ok(guard > 0 && spawn > 0, 'both the guard and the spawn must be present');
44
+ assert.ok(guard < spawn,
45
+ 'checking after spawning would score the ambient count first — the whole bug');
46
+ });
47
+
48
+ test('it returns null (skip) rather than a number when knip is not local', () => {
49
+ // The fail direction matters: a number here is compared against a baseline measured
50
+ // with a LOCAL knip, so returning one is worse than returning nothing.
51
+ const fn = SRC.slice(SRC.indexOf('function knipIssueCount()'));
52
+ const body = fn.slice(0, fn.indexOf('\n}\n'));
53
+ assert.match(body, /if \(!localKnipPath\(\)\)[\s\S]{0,600}return null;/,
54
+ 'the unresolvable case must skip, not score');
55
+ });
56
+
57
+ test('the skip NAMES its cause — a generic line sent readers hunting a broken install', () => {
58
+ assert.equal(typeof ratchets.lastKnipSkipReason, 'function');
59
+ ratchets.knipIssueCount();
60
+ const reason = ratchets.lastKnipSkipReason();
61
+ // In this repo the test runs where knip IS local (CI, main checkout), so the reason
62
+ // is null; where it is NOT local the reason must be specific. Assert whichever case
63
+ // this machine is in, so the test is meaningful on both.
64
+ if (ratchets.localKnipPath()) {
65
+ assert.equal(reason, null, 'a scored run must not claim it was skipped');
66
+ } else {
67
+ assert.ok(reason && /knip/i.test(reason), 'the skip must say what was missing');
68
+ assert.match(reason, /node_modules/, 'and where it looked');
69
+ }
70
+ });
71
+
72
+ test('localKnipPath finds a knip that IS local, and reports none when it is not', () => {
73
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'knipprobe-'));
74
+ try {
75
+ // The predicate is about THIS repo's root, so prove it against the real answer
76
+ // rather than a reimplementation: it must agree with the filesystem.
77
+ const expected = ['node_modules/knip/package.json', 'node_modules/.bin/knip']
78
+ .some((rel) => fs.existsSync(path.join(REPO_ROOT, rel)));
79
+ assert.equal(!!ratchets.localKnipPath(), expected,
80
+ 'localKnipPath must agree with what is actually on disk');
81
+ } finally {
82
+ fs.rmSync(tmp, { recursive: true, force: true });
83
+ }
84
+ });
85
+
86
+ test('the caller fails OPEN on a skip — an unscorable metric is not a violation', () => {
87
+ assert.match(SRC, /knip not scored: \$\{lastKnipSkipReason\(\)/,
88
+ 'the warning must carry the specific reason');
89
+ const warnIdx = SRC.indexOf('knip not scored:');
90
+ const after = SRC.slice(warnIdx, warnIdx + 300);
91
+ assert.match(after, /process\.exit\(0\)/,
92
+ 'a skip must exit 0 — failing closed here is what this task removed');
93
+ });
94
+
95
+ test('a REAL regression still fails closed — the fix narrows nothing', () => {
96
+ // Guard against "fixed" by making the whole metric advisory.
97
+ const knipOnly = SRC.slice(SRC.indexOf('if (knipOnly)'));
98
+ const block = knipOnly.slice(0, knipOnly.indexOf('return;'));
99
+ assert.match(block, /violations\.length/, 'violations must still be evaluated');
100
+ assert.match(block, /process\.exit\(1\)/, 'and still fail the gate when present');
101
+ });
@@ -52,6 +52,10 @@ async function createdFloor({ scopeModules, needsMigration = false, requiresRank
52
52
  await createTask({
53
53
  versionId: 'BONGOS-V2',
54
54
  title: 'a task',
55
+ // Stated rather than inferred (task 1002988): createTask now refuses a craft it
56
+ // cannot work out, and "a task" says nothing. This fixture is about the RANK
57
+ // FLOOR, so it should not be at the mercy of the discipline heuristic.
58
+ discipline: 'engineer',
55
59
  goalId: '1000111',
56
60
  needsMigration,
57
61
  requiresRank,
@@ -210,7 +210,7 @@ async function withStubbedCreatePool(run) {
210
210
 
211
211
  t('a task CREATED with NO reward is born with a positive, capped value', async () => {
212
212
  await withStubbedCreatePool(async (captured) => {
213
- const row = await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 40 });
213
+ const row = await db.createTask({ versionId: 1, goalId: 77, title: 't', discipline: 'engineer', kind: 'feature', estMinutes: 40 });
214
214
  assert.ok(captured.creditsReward > 0, 'INSERT must bind a positive credits_reward');
215
215
  assert.equal(captured.creditsReward, 40, 'feature @40min -> 40 drachmae');
216
216
  assert.ok(captured.creditsReward <= db.REWARD_SUGGESTION_CAP, 'must respect the cap');
@@ -220,7 +220,7 @@ t('a task CREATED with NO reward is born with a positive, capped value', async (
220
220
 
221
221
  t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', async () => {
222
222
  await withStubbedCreatePool(async (captured) => {
223
- await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 0, kind: 'bug', estMinutes: 40 });
223
+ await db.createTask({ versionId: 1, goalId: 77, title: 't', discipline: 'engineer', creditsReward: 0, kind: 'bug', estMinutes: 40 });
224
224
  // bug weight 1.25 -> 50; 0 is treated as "unset" so the default kicks in.
225
225
  assert.equal(captured.creditsReward, 50);
226
226
  });
@@ -228,14 +228,14 @@ t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', a
228
228
 
229
229
  t('a huge-est new task is CAPPED at creation, not ballooned', async () => {
230
230
  await withStubbedCreatePool(async (captured) => {
231
- await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 9999 });
231
+ await db.createTask({ versionId: 1, goalId: 77, title: 't', discipline: 'engineer', kind: 'feature', estMinutes: 9999 });
232
232
  assert.equal(captured.creditsReward, db.REWARD_SUGGESTION_CAP);
233
233
  });
234
234
  });
235
235
 
236
236
  t('an EXPLICIT positive credits_reward is PRESERVED (never overridden)', async () => {
237
237
  await withStubbedCreatePool(async (captured) => {
238
- await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 17, kind: 'feature', estMinutes: 40 });
238
+ await db.createTask({ versionId: 1, goalId: 77, title: 't', discipline: 'engineer', creditsReward: 17, kind: 'feature', estMinutes: 40 });
239
239
  assert.equal(captured.creditsReward, 17, 'author-set positive value must survive untouched');
240
240
  });
241
241
  });
@@ -0,0 +1,163 @@
1
+ // tests/task_discipline_required.mjs — task 1002988: every task row carries a craft.
2
+ //
3
+ // The thing under test is mostly an ABSENCE: that 'unclassified' can no longer be
4
+ // written, by any vector, and that the DB constraint no longer accepts it. A suite
5
+ // that only proved the four valid values work would have passed before this task and
6
+ // will pass again once the guard rots, so each block below pins the refusal.
7
+ import assert from 'node:assert/strict';
8
+ import { test } from 'node:test';
9
+ import { createRequire } from 'node:module';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const classifier = require(path.join(ROOT, 'modules', 'lifecycle', 'task-classifier.js'));
17
+ const { resolveWriteDiscipline, WRITABLE_DISCIPLINES, VALID_DISCIPLINES } = classifier;
18
+
19
+ // --- the writable set -------------------------------------------------------
20
+
21
+ test('the writable set is the four crafts — ui included, unclassified excluded', () => {
22
+ // FOUR, not three. The task was written 2026-08-16 naming three; ADR 0272
23
+ // (2026-09-09) keeps `ui` as a module-contributed discipline and the owner
24
+ // reconfirmed it on 2026-09-17 when abandoning task 1002987. Narrowing to three
25
+ // would break live ui rows — the backfill found 3 of them by classification alone.
26
+ assert.deepEqual([...WRITABLE_DISCIPLINES].sort(), ['artist', 'engineer', 'ideator', 'ui']);
27
+ assert.ok(!WRITABLE_DISCIPLINES.has('unclassified'), 'unclassified is a triage state, not a craft');
28
+ });
29
+
30
+ test('the READ set still carries unclassified, so old rows and filters resolve', () => {
31
+ // The two sets are deliberately different. Collapsing them would break the
32
+ // ?discipline=unclassified query the backfill itself reads through.
33
+ assert.ok(VALID_DISCIPLINES.has('unclassified'));
34
+ });
35
+
36
+ // --- resolveWriteDiscipline -------------------------------------------------
37
+
38
+ test('an explicit valid discipline is honoured verbatim', () => {
39
+ for (const d of WRITABLE_DISCIPLINES) {
40
+ assert.deepEqual(resolveWriteDiscipline({ discipline: d, title: 'anything' }), { discipline: d });
41
+ }
42
+ });
43
+
44
+ test('an omitted discipline is INFERRED, and reports that it was', () => {
45
+ const r = resolveWriteDiscipline({ title: 'Refactor the database migration script' });
46
+ assert.equal(r.discipline, 'engineer');
47
+ assert.equal(r.inferred, true, 'the caller must be able to tell a guess from a statement');
48
+ assert.ok(r.confidence >= 0.5);
49
+ });
50
+
51
+ test('an explicit "unclassified" is treated as no answer at all, not as a value', () => {
52
+ // modules/ideas/inbox.js sent this literal string on every promotion. If it were
53
+ // honoured as a value, the create path would still mint the rows this task removes.
54
+ const r = resolveWriteDiscipline({ discipline: 'unclassified', title: 'Refactor the database migration script' });
55
+ assert.equal(r.discipline, 'engineer');
56
+ assert.equal(r.inferred, true);
57
+ });
58
+
59
+ test('an unclassifiable task is REFUSED, not filed under a filler value', () => {
60
+ const r = resolveWriteDiscipline({ title: 'asdf' });
61
+ assert.equal(r.discipline, undefined);
62
+ assert.equal(r.error, 'bad_discipline');
63
+ assert.equal(r.unclassifiable, true, 'the caller must be able to say WHY, to write a useful message');
64
+ });
65
+
66
+ test('a bogus discipline is refused, and is NOT reported as unclassifiable', () => {
67
+ const r = resolveWriteDiscipline({ discipline: 'wizard', title: 'Refactor the database migration script' });
68
+ assert.equal(r.error, 'bad_discipline');
69
+ assert.ok(!r.unclassifiable, 'the text was fine — the caller named a craft that does not exist');
70
+ });
71
+
72
+ // --- createTask: the single chokepoint --------------------------------------
73
+ // Every create vector passes through here, including the ones that never touch an
74
+ // HTTP route (idea promotion, copy-desk, newcomer-restock, kickoff-seed, cascade).
75
+ // Fixing only the routes would have left those minting role-less rows.
76
+
77
+ function recordingPool(captured) {
78
+ return {
79
+ query: async (text, params) => {
80
+ if (/^\s*INSERT INTO tasks/i.test(text)) {
81
+ captured.push({ text, params });
82
+ return { rows: [{ id: 1, discipline: params[params.findIndex(() => false)] }] };
83
+ }
84
+ return { rows: [{ id: 1 }] };
85
+ },
86
+ };
87
+ }
88
+
89
+ test('createTask REFUSES a task whose craft it cannot infer', async () => {
90
+ process.env.NODE_ENV = 'test';
91
+ const { createTask } = require(path.join(ROOT, 'modules', 'lifecycle', 'db-tasks.js'));
92
+ const captured = [];
93
+ await assert.rejects(
94
+ () => createTask({ versionId: 'V1', title: 'asdf', goalId: 1 }, { pool: recordingPool(captured) }),
95
+ (e) => e.code === 'BAD_DISCIPLINE' && e.unclassifiable === true,
96
+ 'an unclassifiable create must throw, not default',
97
+ );
98
+ assert.equal(captured.length, 0, 'nothing may be inserted when the craft is unknown');
99
+ });
100
+
101
+ test('createTask no longer has an "unclassified" default to fall back on', () => {
102
+ // The default was the whole mechanism: 1,189 rows accumulated because it was quiet.
103
+ // Pinned as source text because the behavioural test above can only prove the
104
+ // refusal, not that the default is gone rather than merely unreachable.
105
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-tasks.js'), 'utf8');
106
+ assert.ok(!/^\s*discipline = 'unclassified',\s*$/m.test(src),
107
+ "createTask must not default discipline to 'unclassified'");
108
+ });
109
+
110
+ // --- the migration: the absence is the thing under test ---------------------
111
+
112
+ test('core_247 narrows the CHECK to the four crafts and REJECTS unclassified', () => {
113
+ const sql = fs.readFileSync(path.join(ROOT, 'migrations', 'core_247_discipline_required.sql'), 'utf8');
114
+ const m = sql.match(/CHECK \(discipline IN \(([^)]*)\)\)/);
115
+ assert.ok(m, 'the migration must re-add a discipline CHECK constraint');
116
+ const accepted = m[1].split(',').map((s) => s.trim().replace(/'/g, '')).sort();
117
+ assert.deepEqual(accepted, ['artist', 'engineer', 'ideator', 'ui'],
118
+ 'the accepted set must match WRITABLE_DISCIPLINES exactly');
119
+ assert.ok(!accepted.includes('unclassified'), 'the whole point: the constraint rejects it');
120
+ // The net that makes the migration safe on a checkout where nobody ran the script.
121
+ assert.match(sql, /UPDATE tasks SET discipline = 'engineer'[\s\S]*?WHERE discipline = 'unclassified'/,
122
+ 'the migration must resolve residual rows itself, not assume a script was run');
123
+ });
124
+
125
+ test('the migration constraint and the code agree on the accepted set', () => {
126
+ // Two places state the vocabulary; a drift between them is a 500 at write time
127
+ // (code accepts a value the DB rejects) or dead validation (the reverse).
128
+ const sql = fs.readFileSync(path.join(ROOT, 'migrations', 'core_247_discipline_required.sql'), 'utf8');
129
+ const accepted = sql.match(/CHECK \(discipline IN \(([^)]*)\)\)/)[1]
130
+ .split(',').map((s) => s.trim().replace(/'/g, '')).sort();
131
+ assert.deepEqual(accepted, [...WRITABLE_DISCIPLINES].sort());
132
+ });
133
+
134
+ // --- the create vectors that must keep working ------------------------------
135
+ // Acceptance 6: prove it per path, not in aggregate.
136
+
137
+ test('every direct createTask call site names a discipline or resolves one', () => {
138
+ const sites = [
139
+ ['modules/ideas/inbox.js', /discipline: \(lifecycle\.resolveWriteDiscipline/],
140
+ ['modules/copy-desk/routes/copy-desk.js', /discipline: 'artist'/],
141
+ ['modules/onboarding/newcomer-restock.js', /^\s*discipline,\s*$/m],
142
+ ['modules/lifecycle/kickoff-seed.js', /discipline: t\.discipline \|\| 'engineer'/],
143
+ ['modules/lifecycle/cascade.js', /discipline: rule\.discipline/],
144
+ ['scripts/gds/triage.js', /discipline: resolveWriteDiscipline\(/],
145
+ ];
146
+ for (const [rel, re] of sites) {
147
+ const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
148
+ assert.match(src, re, `${rel} must supply a discipline — createTask now throws without one`);
149
+ }
150
+ });
151
+
152
+ test('init.js no longer clobbers its own discipline default with undefined', () => {
153
+ // The spread ran AFTER `t.discipline || 'engineer'`, so `discipline: undefined` in
154
+ // the extras object reset it to nothing — which is why a freshly scaffolded
155
+ // instance was born with role-less starter tasks.
156
+ // Comment lines are stripped first: the fix's own explanation names the old
157
+ // expression, and a scan that cannot tell code from prose would either fail here
158
+ // forever or be "fixed" by deleting the explanation.
159
+ const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'init.js'), 'utf8')
160
+ .split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n');
161
+ assert.ok(!/discipline: undefined/.test(src), 'a later spread must not undo the default');
162
+ assert.match(src, /discipline: t\.discipline \|\| 'engineer'/);
163
+ });
@@ -47,13 +47,13 @@ const permissivePool = {
47
47
 
48
48
  test('a create with no goalId THROWS instead of quietly filing into a bucket', async () => {
49
49
  await assert.rejects(
50
- () => createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool }),
50
+ () => createTask({ versionId: 'BONGOS-V1', title: 't', discipline: 'engineer' }, { pool: permissivePool }),
51
51
  /goalId is required/,
52
52
  'the old behaviour was to succeed and say nothing');
53
53
  });
54
54
 
55
55
  test('the error tells the caller both ways out', async () => {
56
- const err = await createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool })
56
+ const err = await createTask({ versionId: 'BONGOS-V1', title: 't', discipline: 'engineer' }, { pool: permissivePool })
57
57
  .then(() => null, (e) => e);
58
58
  assert.ok(err, 'must reject');
59
59
  assert.match(err.message, /BV1\.R11/, 'names the rule, so the reason is findable');
@@ -63,7 +63,7 @@ test('the error tells the caller both ways out', async () => {
63
63
  test('only a literal true opts in — a truthy value still throws', async () => {
64
64
  for (const v of [1, 'yes', {}, 'true']) {
65
65
  await assert.rejects(
66
- () => createTask({ versionId: 'BONGOS-V1', title: 't', allowCatchAll: v }, { pool: permissivePool }),
66
+ () => createTask({ versionId: 'BONGOS-V1', title: 't', discipline: 'engineer', allowCatchAll: v }, { pool: permissivePool }),
67
67
  /goalId is required/,
68
68
  `${JSON.stringify(v)} must not open the hatch`);
69
69
  }
@@ -73,7 +73,7 @@ test('naming a goal needs no flag at all', async () => {
73
73
  // The ordinary path must stay ordinary: a caller with a goal never learns this
74
74
  // parameter exists.
75
75
  await createTask(
76
- { versionId: 'BONGOS-V1', title: 't', goalId: 77 },
76
+ { versionId: 'BONGOS-V1', title: 't', discipline: 'engineer', goalId: 77 },
77
77
  { pool: permissivePool, client: permissivePool },
78
78
  );
79
79
  });