@bongos/core 1.19.365 → 1.19.367
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 +93 -28
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +9 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0192-a-category-orients-and-authorises-nothing.md +97 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +117 -1
- package/docs/api-reference.md +11 -3
- package/docs/file-map.md +13 -1
- package/migrations/core_226_work_categories.sql +100 -0
- package/modules/builder-settings/event-sound-prefs.js +14 -4
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
- package/modules/hall-ui/public/alert.wav +0 -0
- package/modules/hall-ui/public/fanfare.wav +0 -0
- package/modules/hall-ui/public/ship.wav +0 -0
- package/modules/hall-ui/public/triumph.wav +0 -0
- package/modules/lifecycle/db-goals.js +5 -5
- package/modules/lifecycle/goal-category.js +273 -0
- package/modules/lifecycle/routes/goals.js +38 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/category-advisory-guard.js +153 -0
- package/scripts/gds/fitness.js +6 -1
- package/scripts/gds/gen-sounds.js +176 -0
- package/scripts/gds/ship-io.js +16 -2
- package/scripts/gds/turn-end-sound.js +74 -0
- package/src/bongos/route-rank-check.js +11 -0
- package/src/module-api.js +5 -1
- package/tests/category_advisory_guard.mjs +135 -0
- package/tests/goal_category.mjs +199 -0
- package/tests/goal_tier.mjs +7 -5
- package/tests/sound_player.mjs +345 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/gen-sounds.js — synthesize the notification WAVs (task 1003272).
|
|
3
|
+
//
|
|
4
|
+
// WHY A GENERATOR RATHER THAN FOUR COMMITTED BINARIES. The 5-event sound system
|
|
5
|
+
// (V3.R90, task 1000781) shipped its configuration — the prefs, the toggles, the
|
|
6
|
+
// master volume, the per-event variant picker — and never shipped the audio. Only
|
|
7
|
+
// chime.wav has ever existed in this repo, so four of the five events resolved to
|
|
8
|
+
// a 404 and played nothing. Two prior attempts to fix "sound is broken" (tasks
|
|
9
|
+
// 1001252 and 1000796) were abandoned without reaching that.
|
|
10
|
+
//
|
|
11
|
+
// An opaque binary nobody can regenerate is how a repo loses assets in the first
|
|
12
|
+
// place (the local player itself vanished in the 1.18.0 re-seed and no commit
|
|
13
|
+
// records it). These are simple synthesized tones, so the honest artifact is the
|
|
14
|
+
// recipe: a few dozen lines of additive synthesis anyone can read, adjust, and
|
|
15
|
+
// re-run. `node scripts/gds/gen-sounds.js` is idempotent — same bytes every time,
|
|
16
|
+
// no timestamps, no randomness — so it can be a CI freshness check like the other
|
|
17
|
+
// generators if that is ever wanted.
|
|
18
|
+
//
|
|
19
|
+
// chime.wav IS NOT TOUCHED. It predates this task and is the one asset the system
|
|
20
|
+
// always had; regenerating it would replace a sound builders already know with a
|
|
21
|
+
// different one for no reason. This writes only the four that were missing.
|
|
22
|
+
//
|
|
23
|
+
// WHERE THEY GO, and why one copy. modules/hall-ui/public/ is served at
|
|
24
|
+
// /builders/<name>.wav, which is exactly what settings.js previews
|
|
25
|
+
// (`new Audio('/builders/' + wavName + '.wav')`). The local player
|
|
26
|
+
// (.claude/sounds/play.js) reads the SAME files from that directory rather than
|
|
27
|
+
// keeping a second copy — two copies of an asset are two things to drift.
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
|
|
34
|
+
const RATE = 44100;
|
|
35
|
+
const OUT_DIR = path.resolve(__dirname, '..', '..', 'modules', 'hall-ui', 'public');
|
|
36
|
+
|
|
37
|
+
// Equal-temperament note table, only the notes these sounds use. Named rather
|
|
38
|
+
// than numeric so the voicings below read as music instead of magic numbers.
|
|
39
|
+
const HZ = {
|
|
40
|
+
A5: 880.00, D6: 1174.66,
|
|
41
|
+
C5: 523.25, E5: 659.25, G5: 783.99,
|
|
42
|
+
C6: 1046.50, E6: 1318.51, G6: 1567.98,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// One struck note: a fundamental plus a little second and third harmonic (a bell
|
|
46
|
+
// is not a sine wave), under an exponential decay with a short fade-in. The
|
|
47
|
+
// fade-in matters — starting a waveform at full amplitude is an audible click.
|
|
48
|
+
function note({ freq, start, dur, gain = 0.5, decay = 4.5 }) {
|
|
49
|
+
const voices = [
|
|
50
|
+
{ mult: 1, amp: 1.0 },
|
|
51
|
+
{ mult: 2, amp: 0.28 },
|
|
52
|
+
{ mult: 3, amp: 0.10 },
|
|
53
|
+
];
|
|
54
|
+
const attack = Math.min(0.006, dur / 8); // seconds
|
|
55
|
+
return { freq, start, dur, gain, decay, voices, attack };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Render a list of notes into one mono float buffer, summed (notes may overlap).
|
|
59
|
+
function render(notes) {
|
|
60
|
+
const end = notes.reduce((m, n) => Math.max(m, n.start + n.dur), 0);
|
|
61
|
+
const total = Math.ceil((end + 0.03) * RATE); // a little tail so nothing cuts
|
|
62
|
+
const buf = new Float64Array(total);
|
|
63
|
+
for (const n of notes) {
|
|
64
|
+
const from = Math.floor(n.start * RATE);
|
|
65
|
+
const len = Math.floor(n.dur * RATE);
|
|
66
|
+
for (let i = 0; i < len; i++) {
|
|
67
|
+
const t = i / RATE;
|
|
68
|
+
const env = Math.exp(-n.decay * t) * Math.min(1, t / n.attack);
|
|
69
|
+
let s = 0;
|
|
70
|
+
for (const v of n.voices) s += v.amp * Math.sin(2 * Math.PI * n.freq * v.mult * t);
|
|
71
|
+
buf[from + i] += s * env * n.gain;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Normalize to a consistent peak so no sound is jarringly louder than another,
|
|
75
|
+
// then a short linear fade-out so the file never ends mid-cycle (another click).
|
|
76
|
+
let peak = 0;
|
|
77
|
+
for (let i = 0; i < total; i++) peak = Math.max(peak, Math.abs(buf[i]));
|
|
78
|
+
const scale = peak > 0 ? 0.82 / peak : 0;
|
|
79
|
+
const fade = Math.min(total, Math.floor(0.02 * RATE));
|
|
80
|
+
for (let i = 0; i < total; i++) {
|
|
81
|
+
let s = buf[i] * scale;
|
|
82
|
+
const fromEnd = total - 1 - i;
|
|
83
|
+
if (fromEnd < fade) s *= fromEnd / fade;
|
|
84
|
+
buf[i] = s;
|
|
85
|
+
}
|
|
86
|
+
return buf;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 16-bit mono PCM, 44100 Hz — byte-for-byte the format of the existing chime.wav,
|
|
90
|
+
// so nothing downstream has to care which file it got.
|
|
91
|
+
function toWav(samples) {
|
|
92
|
+
const dataBytes = samples.length * 2;
|
|
93
|
+
const out = Buffer.alloc(44 + dataBytes);
|
|
94
|
+
out.write('RIFF', 0);
|
|
95
|
+
out.writeUInt32LE(36 + dataBytes, 4);
|
|
96
|
+
out.write('WAVE', 8);
|
|
97
|
+
out.write('fmt ', 12);
|
|
98
|
+
out.writeUInt32LE(16, 16); // fmt chunk size
|
|
99
|
+
out.writeUInt16LE(1, 20); // PCM
|
|
100
|
+
out.writeUInt16LE(1, 22); // mono
|
|
101
|
+
out.writeUInt32LE(RATE, 24);
|
|
102
|
+
out.writeUInt32LE(RATE * 2, 28); // byte rate
|
|
103
|
+
out.writeUInt16LE(2, 32); // block align
|
|
104
|
+
out.writeUInt16LE(16, 34); // bits
|
|
105
|
+
out.write('data', 36);
|
|
106
|
+
out.writeUInt32LE(dataBytes, 40);
|
|
107
|
+
for (let i = 0; i < samples.length; i++) {
|
|
108
|
+
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
|
109
|
+
out.writeInt16LE(Math.round(clamped * 32767), 44 + i * 2);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The four voicings. Each is written to match what the event MEANS — the labels
|
|
115
|
+
// come from modules/builder-settings/sound-prefs.js.
|
|
116
|
+
const SOUNDS = {
|
|
117
|
+
// "When Claude needs your input" — a two-tone ask, rising so it reads as a
|
|
118
|
+
// question. Short and unhurried: this one can fire while someone is thinking.
|
|
119
|
+
alert: [
|
|
120
|
+
note({ freq: HZ.A5, start: 0.00, dur: 0.26, gain: 0.5, decay: 7 }),
|
|
121
|
+
note({ freq: HZ.D6, start: 0.15, dur: 0.40, gain: 0.5, decay: 6 }),
|
|
122
|
+
],
|
|
123
|
+
// "When a task ships" — a settled two-note rise, C5 to G5. A fifth, because it
|
|
124
|
+
// should sound finished rather than expectant.
|
|
125
|
+
ship: [
|
|
126
|
+
note({ freq: HZ.C5, start: 0.00, dur: 0.30, gain: 0.5, decay: 6 }),
|
|
127
|
+
note({ freq: HZ.G5, start: 0.14, dur: 0.55, gain: 0.5, decay: 4.5 }),
|
|
128
|
+
],
|
|
129
|
+
// "When a big task ships" — the same idea carried further: a major triad
|
|
130
|
+
// arpeggio landing on the octave.
|
|
131
|
+
triumph: [
|
|
132
|
+
note({ freq: HZ.C5, start: 0.00, dur: 0.26, gain: 0.42, decay: 7 }),
|
|
133
|
+
note({ freq: HZ.E5, start: 0.11, dur: 0.26, gain: 0.42, decay: 7 }),
|
|
134
|
+
note({ freq: HZ.G5, start: 0.22, dur: 0.30, gain: 0.46, decay: 6 }),
|
|
135
|
+
note({ freq: HZ.C6, start: 0.34, dur: 0.75, gain: 0.55, decay: 3.4 }),
|
|
136
|
+
],
|
|
137
|
+
// "When you earn a rank promotion" — the brightest of the four, a four-note
|
|
138
|
+
// fanfare that overlaps into a held top note.
|
|
139
|
+
fanfare: [
|
|
140
|
+
note({ freq: HZ.G5, start: 0.00, dur: 0.22, gain: 0.40, decay: 8 }),
|
|
141
|
+
note({ freq: HZ.C6, start: 0.10, dur: 0.22, gain: 0.44, decay: 8 }),
|
|
142
|
+
note({ freq: HZ.E6, start: 0.20, dur: 0.26, gain: 0.46, decay: 7 }),
|
|
143
|
+
note({ freq: HZ.G6, start: 0.31, dur: 0.90, gain: 0.55, decay: 3.0 }),
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
function main() {
|
|
148
|
+
const check = process.argv.includes('--check');
|
|
149
|
+
const written = [];
|
|
150
|
+
const stale = [];
|
|
151
|
+
for (const [name, notes] of Object.entries(SOUNDS)) {
|
|
152
|
+
const file = path.join(OUT_DIR, `${name}.wav`);
|
|
153
|
+
const wav = toWav(render(notes));
|
|
154
|
+
let current = null;
|
|
155
|
+
try { current = fs.readFileSync(file); } catch { /* absent */ }
|
|
156
|
+
if (current && current.equals(wav)) continue;
|
|
157
|
+
if (check) { stale.push(path.relative(path.resolve(__dirname, '..', '..'), file)); continue; }
|
|
158
|
+
fs.writeFileSync(file, wav);
|
|
159
|
+
written.push(`${name}.wav (${wav.length} bytes)`);
|
|
160
|
+
}
|
|
161
|
+
if (check) {
|
|
162
|
+
if (stale.length) {
|
|
163
|
+
console.error(`gen-sounds --check: STALE — would rewrite ${stale.join(', ')}`);
|
|
164
|
+
console.error(' run: node scripts/gds/gen-sounds.js (and commit the result)');
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
console.log('gen-sounds --check: up to date.');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
console.log(written.length
|
|
171
|
+
? `gen-sounds: wrote ${written.join(', ')}`
|
|
172
|
+
: 'gen-sounds: all sounds already current — no changes.');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (require.main === module) main();
|
|
176
|
+
module.exports = { render, toWav, SOUNDS, RATE };
|
package/scripts/gds/ship-io.js
CHANGED
|
@@ -14,8 +14,7 @@ const { playSound } = require('./ship-state.js');
|
|
|
14
14
|
function playShipBell() {
|
|
15
15
|
let shipWav = 'ship';
|
|
16
16
|
try {
|
|
17
|
-
const
|
|
18
|
-
const raw = require('fs').readFileSync(prefsPath, 'utf8');
|
|
17
|
+
const raw = require('fs').readFileSync(shipPrefsPath(), 'utf8');
|
|
19
18
|
const parsed = JSON.parse(raw);
|
|
20
19
|
const ev = parsed && parsed.event_sounds && parsed.event_sounds['task-shipped'];
|
|
21
20
|
if (typeof ev === 'string') shipWav = ev;
|
|
@@ -23,6 +22,20 @@ function playShipBell() {
|
|
|
23
22
|
playSound(shipWav);
|
|
24
23
|
}
|
|
25
24
|
|
|
25
|
+
// task 1003272: this used to hardcode ~/.config/otb/, the PRE-RENAME location,
|
|
26
|
+
// while fetch-sound-prefs.js writes the mirror through
|
|
27
|
+
// instance-config.configPath() — today ~/.config/cloudbongos/. Reader and writer
|
|
28
|
+
// were looking at different files, so a builder's chosen ship sound was never
|
|
29
|
+
// found and this always fell through to the 'ship' default. Resolve the SAME way
|
|
30
|
+
// the writer does, with the legacy path as a fallback for an older box.
|
|
31
|
+
function shipPrefsPath() {
|
|
32
|
+
try {
|
|
33
|
+
const p = require('../../src/instance-config').configPath('sound-prefs.json');
|
|
34
|
+
if (require('fs').existsSync(p)) return p;
|
|
35
|
+
} catch (_) { /* fall through to the legacy location */ }
|
|
36
|
+
return require('path').join(os.homedir(), '.config', 'otb', 'sound-prefs.json');
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
// task 1002809: normalize an API error body across BOTH wire shapes — the live
|
|
27
40
|
// ADR 0116 (R01) envelope `{ error: { code, message } }` and the legacy flat
|
|
28
41
|
// `{ error: 'code' }`. Same normalization as claim.js:formatClaimFailure and
|
|
@@ -93,6 +106,7 @@ function postLandShipOutcome({ code, liveStatus }) {
|
|
|
93
106
|
// the early returns are control flow only.
|
|
94
107
|
|
|
95
108
|
module.exports = {
|
|
109
|
+
shipPrefsPath,
|
|
96
110
|
apiErrorLine,
|
|
97
111
|
normalizeApiError,
|
|
98
112
|
playShipBell,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/turn-end-sound.js — the Stop hook that rings when a turn ends
|
|
3
|
+
// (task 1003272).
|
|
4
|
+
//
|
|
5
|
+
// THE TRIGGER THAT NEVER EXISTED. scripts/gds/fetch-sound-prefs.js has always
|
|
6
|
+
// described "a LOCAL Stop hook + ship.js via .claude/sounds/play.js", and
|
|
7
|
+
// sound-prefs.js has always labelled `chime` as "When Claude finishes a turn" —
|
|
8
|
+
// but .claude/settings.json had no Stop hook at all. So the single most
|
|
9
|
+
// noticeable sound in the system had nothing to fire it, on top of the player
|
|
10
|
+
// being absent and four of the five WAVs never having been committed. Three
|
|
11
|
+
// independent gaps, one symptom: "all sound seems bugged".
|
|
12
|
+
//
|
|
13
|
+
// WHY A SCRIPT RATHER THAN AN INLINE HOOK COMMAND. The variant resolution below
|
|
14
|
+
// is the same logic ship-io.js's playShipBell() already uses (read the mirrored
|
|
15
|
+
// prefs, take the event's chosen wav, fall back to a base sound). That is a
|
|
16
|
+
// couple of lines too many for a JSON string, and a hook that cannot be read is
|
|
17
|
+
// a hook nobody will fix.
|
|
18
|
+
//
|
|
19
|
+
// IT MUST ALWAYS EXIT 0 AND FAST. This runs at the end of every turn. It resolves
|
|
20
|
+
// a name, hands it to a fire-and-forget player that spawns detached, and returns;
|
|
21
|
+
// every failure path is a silent no-op. A Stop hook that threw, or that blocked
|
|
22
|
+
// on audio, would make a cosmetic feature everyone's problem.
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
|
|
30
|
+
// `user-input` is the event whose meaning matches a turn ending — Claude has
|
|
31
|
+
// paused and it is the builder's move. Falls back to `chime`, which is the base
|
|
32
|
+
// sound sound-prefs.js labels "When Claude finishes a turn".
|
|
33
|
+
const EVENT_ID = 'user-input';
|
|
34
|
+
const FALLBACK_WAV = 'chime';
|
|
35
|
+
|
|
36
|
+
// Resolve the mirror the SAME way fetch-sound-prefs.js writes it
|
|
37
|
+
// (instance-config.configPath — today ~/.config/cloudbongos/), with the
|
|
38
|
+
// pre-rename ~/.config/otb/ as a fallback for an older box. Hardcoding the
|
|
39
|
+
// legacy path is the bug this task found in two other readers; it is not going
|
|
40
|
+
// to be re-introduced here.
|
|
41
|
+
function prefsPath() {
|
|
42
|
+
try {
|
|
43
|
+
const p = require('../../src/instance-config').configPath('sound-prefs.json');
|
|
44
|
+
if (fs.existsSync(p)) return p;
|
|
45
|
+
} catch (_) { /* fall through */ }
|
|
46
|
+
return path.join(os.homedir(), '.config', 'otb', 'sound-prefs.json');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveWav() {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(fs.readFileSync(prefsPath(), 'utf8'));
|
|
52
|
+
const chosen = parsed && parsed.event_sounds && parsed.event_sounds[EVENT_ID];
|
|
53
|
+
if (typeof chosen === 'string' && chosen) return chosen;
|
|
54
|
+
} catch (_) { /* no mirror yet — the fallback is the honest default */ }
|
|
55
|
+
return FALLBACK_WAV;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function main() {
|
|
59
|
+
try {
|
|
60
|
+
// Guarded exactly like ship-state.js's: on a checkout without the player
|
|
61
|
+
// (or mid-re-seed) this is a no-op, never a failed turn.
|
|
62
|
+
let playSound;
|
|
63
|
+
try {
|
|
64
|
+
({ playSound } = require(path.resolve(__dirname, '..', '..', '.claude', 'sounds', 'play.js')));
|
|
65
|
+
} catch (_) { return; }
|
|
66
|
+
if (typeof playSound === 'function') playSound(resolveWav());
|
|
67
|
+
} catch (_) { /* cosmetic, always */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (require.main === module) {
|
|
71
|
+
main();
|
|
72
|
+
process.exit(0); // never let a stray handle hold the turn open
|
|
73
|
+
}
|
|
74
|
+
module.exports = { resolveWav, prefsPath, EVENT_ID, FALLBACK_WAV };
|
|
@@ -370,6 +370,17 @@ const MODULE_EXPECTED_RANKS = {
|
|
|
370
370
|
// authorizeGoalManagement) corrects its own title/description — same shape
|
|
371
371
|
// and same gate as the visibility route above.
|
|
372
372
|
'PATCH /goals/:id': 'any-builder',
|
|
373
|
+
// task 1003275 (goal 1000072, legibility-categories): the ADVISORY human
|
|
374
|
+
// category. The set is a VOCABULARY, so reading it is any-builder — a client
|
|
375
|
+
// that cannot read it hard-codes the labels. Creating one is a planning act,
|
|
376
|
+
// pinned to the same permission as authoring a goal. Setting a goal's category
|
|
377
|
+
// rides the same owner|manager|Archon in-handler gate as the prose edit above.
|
|
378
|
+
// These pins are also the tripwire for the rule that outranks the feature: a
|
|
379
|
+
// category must never GATE anything, so if one of these ever needs a WIDER
|
|
380
|
+
// gate than its neighbours, the label has grown into a permission.
|
|
381
|
+
'GET /work-categories': 'any-builder',
|
|
382
|
+
'POST /work-categories': 'perm:goal.create',
|
|
383
|
+
'PATCH /goals/:id/category': 'any-builder',
|
|
373
384
|
'POST /goals/:id/requests': 'any-builder',
|
|
374
385
|
'POST /goals/:id/requests/:reqId/respond': 'any-builder',
|
|
375
386
|
'POST /goals/:id/invitations': 'any-builder',
|
package/src/module-api.js
CHANGED
|
@@ -1160,8 +1160,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1160
1160
|
// landed since 1.19.363 with no explicit bump. run 32890225073. (task 1002620)
|
|
1161
1161
|
// 1.19.365 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1162
1162
|
// landed since 1.19.364 with no explicit bump. run 32893443215. (task 1002620)
|
|
1163
|
+
// 1.19.366 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1164
|
+
// landed since 1.19.365 with no explicit bump. run 32895705036. (task 1002620)
|
|
1165
|
+
// 1.19.367 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1166
|
+
// landed since 1.19.366 with no explicit bump. run 32902059088. (task 1002620)
|
|
1163
1167
|
// ---------------------------------------------------------------------------
|
|
1164
|
-
const CORE_VERSION = '1.19.
|
|
1168
|
+
const CORE_VERSION = '1.19.367'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1165
1169
|
|
|
1166
1170
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1167
1171
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// tests/category_advisory_guard.mjs — the guard that keeps a work CATEGORY from
|
|
2
|
+
// becoming a permission (task 1003275, goal 1000072, criterion legibility-categories).
|
|
3
|
+
//
|
|
4
|
+
// The whole point of that criterion's load-bearing constraint is that the failure
|
|
5
|
+
// arrives as ONE plausible line inside a gate, not as a decision anyone announces.
|
|
6
|
+
// So the guard has to be tested on synthetic sources: a scan that passes because
|
|
7
|
+
// today's tree happens to be clean proves nothing about what it would REFUSE
|
|
8
|
+
// tomorrow (the client_baseurl_guard.mjs precedent).
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/category_advisory_guard.mjs
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const { checkCategoryIsAdvisory, scanGates, scanAnchors, GATE_FILES, FEATURE_ANCHORS, stripComments } =
|
|
17
|
+
require('../scripts/gds/category-advisory-guard.js');
|
|
18
|
+
|
|
19
|
+
let passed = 0;
|
|
20
|
+
let failed = 0;
|
|
21
|
+
function test(name, fn) {
|
|
22
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
23
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const from = (map) => (rel) => (rel in map ? map[rel] : null);
|
|
27
|
+
|
|
28
|
+
// --- THE FAILURE THE GUARD EXISTS FOR --------------------------------------
|
|
29
|
+
|
|
30
|
+
test('a claimable feed that hides a category is refused', () => {
|
|
31
|
+
const { violations } = scanGates(['modules/lifecycle/claim-feed.js'], from({
|
|
32
|
+
'modules/lifecycle/claim-feed.js':
|
|
33
|
+
"const rows = await pool.query('SELECT * FROM tasks');\n"
|
|
34
|
+
+ "return rows.filter((t) => t.category_id !== LEGAL_CATEGORY);\n",
|
|
35
|
+
}));
|
|
36
|
+
assert.equal(violations.length, 1);
|
|
37
|
+
assert.match(violations[0], /claim-feed\.js:2/);
|
|
38
|
+
assert.match(violations[0], /ADVISORY/, 'the message states the rule');
|
|
39
|
+
assert.match(violations[0], /ADR 0086/, 'and points at the hierarchy that DOES enforce');
|
|
40
|
+
assert.match(violations[0], /owner decision/, 'and names the only legitimate way to change it');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('an authorisation branch on a category is refused', () => {
|
|
44
|
+
const { violations } = scanGates(['modules/lifecycle/goal-authz.js'], from({
|
|
45
|
+
'modules/lifecycle/goal-authz.js':
|
|
46
|
+
'function authorizeGoalManagement({ goal, actorRank }) {\n'
|
|
47
|
+
+ " if (goal.category === 'security' && actorRank !== 'archon') return { ok: false };\n"
|
|
48
|
+
+ ' return { ok: true };\n}\n',
|
|
49
|
+
}));
|
|
50
|
+
assert.equal(violations.length, 1);
|
|
51
|
+
assert.match(violations[0], /goal-authz\.js:2/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('the plural, the past tense and the raw table name are all caught', () => {
|
|
55
|
+
for (const line of [
|
|
56
|
+
'const c = row.categories;',
|
|
57
|
+
'if (!goal.categorised) return refuse();',
|
|
58
|
+
"await pool.query('SELECT slug FROM work_categories');",
|
|
59
|
+
]) {
|
|
60
|
+
const { violations } = scanGates(['scripts/gds/gating.js'], from({ 'scripts/gds/gating.js': `${line}\n` }));
|
|
61
|
+
assert.equal(violations.length, 1, `not caught: ${line}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// --- WHAT IT MUST NOT REFUSE ----------------------------------------------
|
|
66
|
+
|
|
67
|
+
test('a gate may CITE the rule in a comment', () => {
|
|
68
|
+
const { violations } = scanGates(['modules/lifecycle/claim-eligibility.js'], from({
|
|
69
|
+
'modules/lifecycle/claim-eligibility.js':
|
|
70
|
+
'// A work category is advisory and is deliberately NOT read here (task 1003275).\n'
|
|
71
|
+
+ '/* Not even the category set — see ADR 0192. */\n'
|
|
72
|
+
+ 'return { ok: true };\n',
|
|
73
|
+
}));
|
|
74
|
+
assert.deepEqual(violations, [], 'citing the rule is the whole reason comments are stripped');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a gate that reads scope, rank or membership is untouched', () => {
|
|
78
|
+
const { violations } = scanGates(['src/bongos/goal-scope-check.js'], from({
|
|
79
|
+
'src/bongos/goal-scope-check.js':
|
|
80
|
+
"if (scopeIncludesProtected(goal.scope_modules) && rank !== 'archon') return refuse();\n",
|
|
81
|
+
}));
|
|
82
|
+
assert.deepEqual(violations, []);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a word that merely CONTAINS the letters is not a category', () => {
|
|
86
|
+
const { violations } = scanGates(['src/bongos/auth.js'], from({
|
|
87
|
+
'src/bongos/auth.js': 'const c = categorizeNothing;\nconst d = subcategory_id_like;\n',
|
|
88
|
+
}));
|
|
89
|
+
assert.deepEqual(violations, [], 'the word boundary matters — `categorize` alone is not `category`');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// --- THE GUARD MUST NOT BE ABLE TO PASS VACUOUSLY -------------------------
|
|
93
|
+
|
|
94
|
+
test('a renamed-away feature fails the guard instead of passing it silently', () => {
|
|
95
|
+
const violations = scanAnchors(
|
|
96
|
+
[{ file: 'modules/lifecycle/goal-category.js', needle: 'decorateGoalCategory' }],
|
|
97
|
+
from({ 'modules/lifecycle/goal-category.js': '// gutted\n' })
|
|
98
|
+
);
|
|
99
|
+
assert.equal(violations.length, 1);
|
|
100
|
+
assert.match(violations[0], /vacuous/, 'it says WHY a green tick here would be a lie');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a deleted feature file fails the guard', () => {
|
|
104
|
+
const violations = scanAnchors(FEATURE_ANCHORS.slice(0, 1), from({}));
|
|
105
|
+
assert.equal(violations.length, 1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// --- A DRIFTED LIST WARNS, IT DOES NOT PASS -------------------------------
|
|
109
|
+
|
|
110
|
+
test('a gate file that has moved warns rather than silently shrinking the scan', () => {
|
|
111
|
+
const { violations, warnings, scanned } = scanGates(['modules/lifecycle/gone.js'], from({}));
|
|
112
|
+
assert.deepEqual(violations, []);
|
|
113
|
+
assert.equal(scanned, 0);
|
|
114
|
+
assert.equal(warnings.length, 1);
|
|
115
|
+
assert.match(warnings[0], /drifted/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// --- COMMENT STRIPPING KEEPS TEXT WHEN UNSURE ----------------------------
|
|
119
|
+
|
|
120
|
+
test('stripComments errs toward KEEPING text (a guard should fail loud, not quiet)', () => {
|
|
121
|
+
assert.match(stripComments("const url = 'http://x/category';\n"), /category/,
|
|
122
|
+
'a `//` inside a string must not be able to hide a real read from the scan');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// --- THE LIVE TREE -------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
test('the live tree passes, and the gate list is not empty', () => {
|
|
128
|
+
assert.ok(GATE_FILES.length >= 10, 'a decision-surface list this short would not be covering the gates');
|
|
129
|
+
const r = checkCategoryIsAdvisory();
|
|
130
|
+
assert.deepEqual(r.violations, [], r.violations.join('\n'));
|
|
131
|
+
assert.equal(r.hardFail, false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
135
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// tests/goal_category.mjs — the human CATEGORY on a goal, and the rule that
|
|
2
|
+
// outranks it (task 1003275, goal 1000072, criterion legibility-categories).
|
|
3
|
+
//
|
|
4
|
+
// Two things are pinned here, and the second matters more than the first.
|
|
5
|
+
//
|
|
6
|
+
// 1. The feature works: the set is readable, a category can be created at the
|
|
7
|
+
// moment Claude could not place a goal, a goal can be labelled and unlabelled,
|
|
8
|
+
// and a board of goals costs ONE category query rather than a join per goal.
|
|
9
|
+
//
|
|
10
|
+
// 2. UNCATEGORISED IS LEGAL. The criterion is explicit that Claude failing to
|
|
11
|
+
// place a goal is "a designed prompt, not an error". The moment a missing
|
|
12
|
+
// category can refuse a write, the label has become a gate — which is the exact
|
|
13
|
+
// failure the goal's own prose predicts ("a second thing that LOOKS like a
|
|
14
|
+
// hierarchy will be mistaken for one within a release"). The
|
|
15
|
+
// scripts/gds/category-advisory-guard.js check covers the gates; these cover
|
|
16
|
+
// the write paths.
|
|
17
|
+
//
|
|
18
|
+
// Run: node --test tests/goal_category.mjs (or: node tests/goal_category.mjs)
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const gc = require(path.join(ROOT, 'modules', 'lifecycle', 'goal-category.js'));
|
|
29
|
+
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
|
|
30
|
+
|
|
31
|
+
const fakeRes = () => ({
|
|
32
|
+
code: null, statusCode: null, payload: null,
|
|
33
|
+
fail(code, status, extra) { this.code = code; this.statusCode = typeof status === 'number' ? status : status?.status ?? 400; this.payload = { error: code, ...(extra || {}) }; return this; },
|
|
34
|
+
status(c) { this.statusCode = c; return this; },
|
|
35
|
+
json(p) { this.payload = p; return this; },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ---- slugs -----------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
test('a label a human types reduces to a usable slug', () => {
|
|
41
|
+
assert.equal(gc.slugify('Government (Decision Making)'), 'government-decision-making');
|
|
42
|
+
assert.equal(gc.slugify('User/ABI Experience'), 'user-abi-experience');
|
|
43
|
+
assert.equal(gc.slugify(' Developer Relations '), 'developer-relations');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a slug that could break a client filter is refused, not silently mangled', () => {
|
|
47
|
+
assert.equal(gc.isValidSlug('security'), true);
|
|
48
|
+
assert.equal(gc.isValidSlug('user-experience'), true);
|
|
49
|
+
assert.equal(gc.isValidSlug('a'), false, 'one char is not a key');
|
|
50
|
+
assert.equal(gc.isValidSlug('-legal'), false);
|
|
51
|
+
assert.equal(gc.isValidSlug('legal-'), false);
|
|
52
|
+
assert.equal(gc.isValidSlug('a--b'), false);
|
|
53
|
+
assert.equal(gc.isValidSlug('Legal'), false, 'case matters — two spellings of one category is not a set');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a label of pure punctuation yields no slug, and the route says so', async () => {
|
|
57
|
+
// slugify('***') is '' — isValidSlug rejects it, so the caller gets a message
|
|
58
|
+
// telling them to send an explicit slug rather than a mystery 400.
|
|
59
|
+
const handler = gc.categoryCreateHandler({ create: async () => { throw new Error('must not reach the DB'); }, validateOrRespond: () => false, LIMITS: { TITLE: 200 } });
|
|
60
|
+
const res = fakeRes();
|
|
61
|
+
await handler({ body: { label: '***' }, builder: { id: 1 } }, res);
|
|
62
|
+
assert.equal(res.code, 'bad_slug');
|
|
63
|
+
assert.match(res.payload.message, /send a `slug` explicitly/i);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ---- creating one at the moment Claude could not place a goal ---------------
|
|
67
|
+
|
|
68
|
+
test('creating a category returns 201 and attributes it to the human who made it', async () => {
|
|
69
|
+
let seen = null;
|
|
70
|
+
const handler = gc.categoryCreateHandler({
|
|
71
|
+
create: async (args) => { seen = args; return { id: 9, slug: args.slug, label: args.label, created: true }; },
|
|
72
|
+
validateOrRespond: () => false, LIMITS: { TITLE: 200 },
|
|
73
|
+
});
|
|
74
|
+
const res = fakeRes();
|
|
75
|
+
await handler({ body: { label: 'Developer Relations' }, builder: { id: 42 } }, res);
|
|
76
|
+
assert.equal(res.statusCode, 201);
|
|
77
|
+
assert.equal(res.payload.created, true);
|
|
78
|
+
assert.deepEqual(seen, { slug: 'developer-relations', label: 'Developer Relations', createdBy: 42 });
|
|
79
|
+
assert.equal('created' in res.payload.category, false, 'the internal created flag does not leak onto the row');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a duplicate converges on the existing category instead of stranding a goal', async () => {
|
|
83
|
+
// Two people prompted to create "Developer Relations" in the same hour must end
|
|
84
|
+
// up with ONE category. A 409 here would leave whichever lost the race with no
|
|
85
|
+
// label and no obvious next step — the opposite of a designed prompt.
|
|
86
|
+
const handler = gc.categoryCreateHandler({
|
|
87
|
+
create: async ({ slug }) => ({ id: 9, slug, label: 'Developer Relations', created: false }),
|
|
88
|
+
validateOrRespond: () => false, LIMITS: { TITLE: 200 },
|
|
89
|
+
});
|
|
90
|
+
const res = fakeRes();
|
|
91
|
+
await handler({ body: { label: 'developer relations' }, builder: { id: 7 } }, res);
|
|
92
|
+
assert.equal(res.statusCode, 200, 'not a 201, and not an error');
|
|
93
|
+
assert.equal(res.payload.created, false);
|
|
94
|
+
assert.equal(res.payload.category.id, 9);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---- labelling a goal ------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
const setDeps = {
|
|
100
|
+
db: { getGoal: async () => ({ id: 7, status: 'open', created_by: 1 }), getGoalMember: async () => null },
|
|
101
|
+
authorizeGoalManagement: () => ({ ok: true }),
|
|
102
|
+
validateOrRespond: () => false,
|
|
103
|
+
parseId: () => 7,
|
|
104
|
+
index: async () => new Map(),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
test('setting a goal\'s category rides the SAME gate as editing its prose', async () => {
|
|
108
|
+
// Not a wider gate and not a narrower one: a label must never change who may
|
|
109
|
+
// write to a goal. Pinning the refusal is how a future widening gets noticed.
|
|
110
|
+
let asked = null;
|
|
111
|
+
const handler = gc.goalCategoryHandler({
|
|
112
|
+
...setDeps,
|
|
113
|
+
authorizeGoalManagement: (args) => { asked = args; return { ok: false, status: 403, body: { error: 'not_yours' } }; },
|
|
114
|
+
set: async () => { throw new Error('must not write when refused'); },
|
|
115
|
+
read: async () => ({ id: 3 }),
|
|
116
|
+
});
|
|
117
|
+
const res = fakeRes();
|
|
118
|
+
await handler({ params: { id: '7' }, body: { category_id: 3 }, builder: { id: 99, rank: 'thetes' } }, res);
|
|
119
|
+
assert.equal(res.statusCode, 403);
|
|
120
|
+
assert.equal(asked.actorId, 99, 'the same authorizeGoalManagement decision the prose edit uses');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('clearing a category is an explicit null, not an omission', async () => {
|
|
124
|
+
let wrote;
|
|
125
|
+
const handler = gc.goalCategoryHandler({ ...setDeps, set: async (a) => { wrote = a; return { id: 7, category_id: null }; }, read: async () => ({ id: 1 }) });
|
|
126
|
+
const res = fakeRes();
|
|
127
|
+
await handler({ params: { id: '7' }, body: { category_id: null }, builder: { id: 1, rank: 'archon' } }, res);
|
|
128
|
+
assert.equal(res.code, null, `must not fail — got ${res.code}`);
|
|
129
|
+
assert.deepEqual(wrote, { goalId: 7, categoryId: null });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('an omitted category_id is a 400 that says what to send', async () => {
|
|
133
|
+
const handler = gc.goalCategoryHandler({ ...setDeps, set: async () => { throw new Error('must not write'); } });
|
|
134
|
+
const res = fakeRes();
|
|
135
|
+
await handler({ params: { id: '7' }, body: {}, builder: { id: 1, rank: 'archon' } }, res);
|
|
136
|
+
assert.equal(res.code, 'category_id_required');
|
|
137
|
+
assert.match(res.payload.message, /category_id: null/, 'the message shows how to clear it too');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('an unknown category is a 404, not a silently-created one', async () => {
|
|
141
|
+
const handler = gc.goalCategoryHandler({ ...setDeps, read: async () => null, set: async () => { throw new Error('must not write'); } });
|
|
142
|
+
const res = fakeRes();
|
|
143
|
+
await handler({ params: { id: '7' }, body: { category_id: 4242 }, builder: { id: 1, rank: 'archon' } }, res);
|
|
144
|
+
assert.equal(res.code, 'category_not_found');
|
|
145
|
+
assert.equal(res.statusCode, 404);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---- decoration ------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
test('a goal carries a readable category, and an uncategorised one carries null', () => {
|
|
151
|
+
const byId = new Map([['3', { id: 3, slug: 'security', label: 'Security' }]]);
|
|
152
|
+
assert.deepEqual(gc.decorateGoalCategory({ id: 1, category_id: 3 }, byId).category,
|
|
153
|
+
{ id: '3', slug: 'security', label: 'Security' });
|
|
154
|
+
assert.equal(gc.decorateGoalCategory({ id: 2, category_id: null }, byId).category, null,
|
|
155
|
+
'uncategorised reads as null — a prompt, never an omission the client has to guess at');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('a goal carrying a RETIRED category still renders its label', () => {
|
|
159
|
+
// Retiring is `active = false`, never a DELETE, and categoryIndex reads inactive
|
|
160
|
+
// rows for exactly this reason: a goal must not silently become uncategorised
|
|
161
|
+
// because someone tidied the set.
|
|
162
|
+
const byId = new Map([['5', { id: 5, slug: 'legal', label: 'Legal', active: false }]]);
|
|
163
|
+
assert.equal(gc.decorateGoalCategory({ id: 1, category_id: 5 }, byId).category.label, 'Legal');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('decoration never mutates the goal it was handed', () => {
|
|
167
|
+
const goal = { id: 1, category_id: 3 };
|
|
168
|
+
gc.decorateGoalCategory(goal, new Map());
|
|
169
|
+
assert.equal('category' in goal, false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---- the rule that outranks the feature ------------------------------------
|
|
173
|
+
|
|
174
|
+
test('a task has no category column — inheritance is literal', () => {
|
|
175
|
+
const migration = read('migrations', 'core_226_work_categories.sql');
|
|
176
|
+
assert.match(migration, /ALTER TABLE goals/);
|
|
177
|
+
assert.equal(/ALTER TABLE tasks/.test(migration), false,
|
|
178
|
+
'a task INHERITS its goal\'s category (criterion wording). A stored copy would be a second answer to one question.');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('the category is nullable with no default, so uncategorised stays a legal state', () => {
|
|
182
|
+
const migration = read('migrations', 'core_226_work_categories.sql');
|
|
183
|
+
assert.match(migration, /ADD COLUMN IF NOT EXISTS category_id bigint REFERENCES work_categories\(id\)/);
|
|
184
|
+
assert.equal(/category_id[^\n]*NOT NULL/.test(migration), false, 'NOT NULL would turn the designed prompt into a refused write');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('the eight defaults the owner named all ship', () => {
|
|
188
|
+
const migration = read('migrations', 'core_226_work_categories.sql');
|
|
189
|
+
for (const label of ['Builders Hall', 'Context Management', 'Government (Decision Making)',
|
|
190
|
+
'Modules', 'Security', 'Economics', 'Legal', 'User/ABI Experience']) {
|
|
191
|
+
assert.ok(migration.includes(label), `missing default category: ${label}`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('POST /goals accepts a category but never requires one', () => {
|
|
196
|
+
const routes = read('modules', 'lifecycle', 'routes', 'goals.js');
|
|
197
|
+
assert.match(routes, /category_id: \{ type: 'integer', min: 1 \}/, 'accepted on create');
|
|
198
|
+
assert.equal(/category_id: \{ required: true/.test(routes), false, 'never required — that would make the prompt a refusal');
|
|
199
|
+
});
|