@bongos/core 1.21.72 → 1.21.74
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 +87 -72
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md +44 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +139 -3
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +40 -36
- package/docs/copy-registry.json +97 -51
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +329 -330
- package/modules/hall-ui/public/atlas.html +3 -3
- package/modules/hall-ui/public/blockers.html +3 -3
- package/modules/hall-ui/public/board-room.html +3 -3
- package/modules/hall-ui/public/city.html +3 -3
- package/modules/hall-ui/public/collab.html +3 -3
- package/modules/hall-ui/public/commands.html +3 -3
- package/modules/hall-ui/public/copy-desk.html +3 -3
- package/modules/hall-ui/public/craft-mode.js +69 -1
- package/modules/hall-ui/public/deploy.html +3 -3
- package/modules/hall-ui/public/diagrams.html +3 -3
- package/modules/hall-ui/public/drachmae.html +3 -3
- package/modules/hall-ui/public/fleet.html +3 -3
- package/modules/hall-ui/public/gate.html +3 -3
- package/modules/hall-ui/public/goal-map.html +3 -3
- package/modules/hall-ui/public/goals.html +3 -3
- package/modules/hall-ui/public/government.html +3 -3
- package/modules/hall-ui/public/idea.html +3 -3
- package/modules/hall-ui/public/ideas.html +3 -3
- package/modules/hall-ui/public/index.html +3 -3
- package/modules/hall-ui/public/modules.html +3 -3
- package/modules/hall-ui/public/primer.html +3 -3
- package/modules/hall-ui/public/profile.html +3 -3
- package/modules/hall-ui/public/project-settings.css +15 -0
- package/modules/hall-ui/public/project-settings.html +7 -7
- package/modules/hall-ui/public/project-settings.js +42 -0
- package/modules/hall-ui/public/ranks.html +3 -3
- package/modules/hall-ui/public/roadmap.html +3 -3
- package/modules/hall-ui/public/roster.html +3 -3
- package/modules/hall-ui/public/sessions.html +3 -3
- package/modules/hall-ui/public/settings.html +3 -3
- package/modules/hall-ui/public/shell.js +9 -0
- package/modules/hall-ui/public/street.html +3 -3
- package/modules/hall-ui/public/studio.html +3 -3
- package/modules/hall-ui/public/task.html +3 -3
- package/modules/hall-ui/public/thinking.html +3 -3
- package/modules/hall-ui/public/tweak-editor.html +3 -3
- package/modules/hall-ui/public/watch.html +3 -3
- package/modules/hall-ui/public/work.html +3 -3
- package/modules/lifecycle/routes/project-settings.js +65 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/shipping-mode.js +71 -0
- package/src/bongos/project-settings.js +67 -0
- package/src/module-api.js +7 -1
- package/tests/craft_mode_nav.mjs +117 -1
- package/tests/hall_city.mjs +5 -3
- package/tests/hall_nav.mjs +3 -1
- package/tests/hall_street.mjs +2 -1
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/project_settings_page.mjs +8 -5
- package/tests/ship_mode.mjs +171 -0
|
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
87
87
|
// the file and left the query alone, so a returning browser kept the cached copy —
|
|
88
88
|
// a fix that reaches nobody looks identical to a fix that does not work.
|
|
89
89
|
|
|
90
|
-
const SHELL_SHA = '
|
|
90
|
+
const SHELL_SHA = '02e600a176c32f6a';
|
|
91
91
|
|
|
92
92
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
93
93
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|
|
@@ -63,8 +63,10 @@ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
|
|
|
63
63
|
resolveOptional: api.resolveOptional,
|
|
64
64
|
list: gc.listCategories,
|
|
65
65
|
set: api.projectSettings.set,
|
|
66
|
+
resolveShipMode: api.projectSettings.resolveShipMode,
|
|
66
67
|
};
|
|
67
68
|
api.resolveRotDays = async () => timer;
|
|
69
|
+
api.projectSettings.resolveShipMode = async () => ({ mode: 'auto', source: 'default', chosen: false, invalid: null });
|
|
68
70
|
// reward: WORKING_REWARD (default) | null (port absent) | 'throws' (rewardConfig raises).
|
|
69
71
|
const rewardPort = reward === 'throws'
|
|
70
72
|
? { rewardConfig: () => { throw new Error('reward boom'); } }
|
|
@@ -80,6 +82,7 @@ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
|
|
|
80
82
|
api.resolveOptional = saved.resolveOptional;
|
|
81
83
|
gc.listCategories = saved.list;
|
|
82
84
|
api.projectSettings.set = saved.set;
|
|
85
|
+
api.projectSettings.resolveShipMode = saved.resolveShipMode;
|
|
83
86
|
};
|
|
84
87
|
// The handler is the LAST layer on the matching route (requireBuilder and the
|
|
85
88
|
// permission gate sit in front); calling it directly is what keeps this a unit test
|
|
@@ -99,22 +102,22 @@ const CATS = [
|
|
|
99
102
|
{ id: 2, slug: 'retired-thing', label: 'Retired thing', sort_order: 20, active: false },
|
|
100
103
|
];
|
|
101
104
|
|
|
102
|
-
test('one read returns all
|
|
105
|
+
test('one read returns all six fundamentals, each naming where its value came from', async () => {
|
|
103
106
|
const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
|
|
104
107
|
try {
|
|
105
108
|
const res = fakeRes();
|
|
106
109
|
await handlerFor('get', '/project-settings')({ query: {} }, res);
|
|
107
110
|
const rows = res.payload.rows;
|
|
108
|
-
assert.equal(rows.length,
|
|
111
|
+
assert.equal(rows.length, 6, 'the page draws six rows, so the read returns six');
|
|
109
112
|
assert.deepEqual(rows.map((r) => r.key),
|
|
110
|
-
['rot_timer', 'work_categories', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
|
|
113
|
+
['ship_mode', 'rot_timer', 'work_categories', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
|
|
111
114
|
// Provenance is the point of the page: every row says where its value came from.
|
|
112
115
|
for (const r of rows) {
|
|
113
116
|
assert.ok(r.reads && r.reads.file, `${r.key} names no file`);
|
|
114
117
|
assert.ok(r.sentence && r.sentence.length > 20, `${r.key} has no readable sentence`);
|
|
115
118
|
}
|
|
116
|
-
//
|
|
117
|
-
assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['rot_timer', 'work_categories']);
|
|
119
|
+
// Three knobs: the criterion's two, plus ship mode (task 1004628).
|
|
120
|
+
assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['ship_mode', 'rot_timer', 'work_categories']);
|
|
118
121
|
// A no-store receipt: a cached settings read is wrong the moment a knob turns.
|
|
119
122
|
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
120
123
|
} finally { restore(); }
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// tests/ship_mode.mjs — the project's ship mode: who starts a ship (task 1004628, ADR 0369).
|
|
2
|
+
//
|
|
3
|
+
// Pinned here:
|
|
4
|
+
// 1. the parser accepts exactly auto|manual (trimmed, any case) and explains a refusal;
|
|
5
|
+
// 2. the resolver defaults to AUTO, tells "never chosen" apart from "chose auto",
|
|
6
|
+
// reports an unusable stored value instead of throwing, and survives a dead DB;
|
|
7
|
+
// 3. the routes: any builder can READ it (the AI must know), the write validates
|
|
8
|
+
// through the same parser and echoes what the system will now read;
|
|
9
|
+
// 4. the CLI verb is registered and its text names how to change the mode.
|
|
10
|
+
//
|
|
11
|
+
// Run: node --test tests/ship_mode.mjs
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
|
|
21
|
+
const poolMod = require(path.join(ROOT, 'src', 'bongos', 'pool.js'));
|
|
22
|
+
// Destructured from a literal relative path, so the dead-code scan (knip) can see
|
|
23
|
+
// these exports are used — a computed path.join() is invisible to it.
|
|
24
|
+
const {
|
|
25
|
+
SHIP_MODES, DEFAULT_SHIP_MODE, parseShipMode, resolveShipMode, describeShipMode,
|
|
26
|
+
} = require('../src/bongos/project-settings.js');
|
|
27
|
+
const ps = { SHIP_MODES, DEFAULT_SHIP_MODE, parseShipMode, resolveShipMode, describeShipMode };
|
|
28
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
29
|
+
|
|
30
|
+
// Swap the pool's query for the duration of one call; project-settings reads
|
|
31
|
+
// `pool.query` at call time, so this reaches the real SQL path.
|
|
32
|
+
async function withPool(query, fn) {
|
|
33
|
+
const pool = poolMod.pool;
|
|
34
|
+
const saved = pool.query;
|
|
35
|
+
pool.query = query;
|
|
36
|
+
try { return await fn(); } finally { pool.query = saved; }
|
|
37
|
+
}
|
|
38
|
+
const rowWith = (value) => async () => ({ rows: value == null ? [] : [{ key: 'ship.mode', value, updated_at: null, updated_by: 1 }] });
|
|
39
|
+
|
|
40
|
+
test('parseShipMode accepts exactly auto|manual, trimmed and in any case', () => {
|
|
41
|
+
assert.deepEqual([...ps.SHIP_MODES], ['auto', 'manual']);
|
|
42
|
+
assert.deepEqual(ps.parseShipMode('auto'), { mode: 'auto' });
|
|
43
|
+
assert.deepEqual(ps.parseShipMode(' Manual '), { mode: 'manual' });
|
|
44
|
+
assert.equal(ps.parseShipMode('').reason, 'empty');
|
|
45
|
+
assert.equal(ps.parseShipMode(null).reason, 'empty');
|
|
46
|
+
assert.match(ps.parseShipMode('yolo').reason, /auto \| manual/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('the default is auto, and an unset project reads as NOT chosen', async () => {
|
|
50
|
+
assert.equal(ps.DEFAULT_SHIP_MODE, 'auto');
|
|
51
|
+
const r = await withPool(rowWith(null), () => ps.resolveShipMode());
|
|
52
|
+
assert.deepEqual(r, { mode: 'auto', source: 'default', chosen: false, invalid: null });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a stored choice is read back as chosen — even when it equals the default', async () => {
|
|
56
|
+
const auto = await withPool(rowWith('auto'), () => ps.resolveShipMode());
|
|
57
|
+
assert.equal(auto.mode, 'auto');
|
|
58
|
+
assert.equal(auto.chosen, true, 'choosing auto is different from never choosing');
|
|
59
|
+
assert.equal(auto.source, 'project_settings');
|
|
60
|
+
const manual = await withPool(rowWith('manual'), () => ps.resolveShipMode());
|
|
61
|
+
assert.equal(manual.mode, 'manual');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('an unusable stored value falls back to auto and is reported, not thrown', async () => {
|
|
65
|
+
const r = await withPool(rowWith('sometimes'), () => ps.resolveShipMode());
|
|
66
|
+
assert.equal(r.mode, 'auto');
|
|
67
|
+
assert.equal(r.chosen, false);
|
|
68
|
+
assert.equal(r.invalid.raw, 'sometimes');
|
|
69
|
+
assert.match(r.invalid.where, /ship\.mode/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a database that cannot be read answers the default instead of failing a ship', async () => {
|
|
73
|
+
const r = await withPool(async () => { throw new Error('db down'); }, () => ps.resolveShipMode());
|
|
74
|
+
assert.deepEqual(r, { mode: 'auto', source: 'default', chosen: false, invalid: null });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('describeShipMode says what each mode means in plain words', () => {
|
|
78
|
+
assert.match(ps.describeShipMode({ mode: 'auto', chosen: true }), /ships and lands it/);
|
|
79
|
+
assert.match(ps.describeShipMode({ mode: 'manual', chosen: true }), /waits for a person/);
|
|
80
|
+
assert.match(ps.describeShipMode({ mode: 'auto', chosen: false }), /nobody has chosen yet/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---- the routes -------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const fakeRes = () => ({
|
|
86
|
+
code: null, statusCode: 200, payload: null, headers: {},
|
|
87
|
+
fail(code, opts) { this.code = code; this.statusCode = opts?.status ?? 400; this.payload = { error: code, ...(opts || {}) }; return this; },
|
|
88
|
+
status(c) { this.statusCode = c; return this; },
|
|
89
|
+
set(k, v) { this.headers[k] = v; return this; },
|
|
90
|
+
json(p) { this.payload = p; return this; },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function buildRouter({ resolved }) {
|
|
94
|
+
const saved = { resolve: api.projectSettings.resolveShipMode, set: api.projectSettings.set };
|
|
95
|
+
const writes = [];
|
|
96
|
+
let current = resolved;
|
|
97
|
+
api.projectSettings.resolveShipMode = async () => current;
|
|
98
|
+
api.projectSettings.set = async (key, value, by) => {
|
|
99
|
+
writes.push({ key, value, by });
|
|
100
|
+
current = { mode: value, source: 'project_settings', chosen: true, invalid: null };
|
|
101
|
+
return { key, value };
|
|
102
|
+
};
|
|
103
|
+
const file = path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js');
|
|
104
|
+
delete require.cache[require.resolve(file)];
|
|
105
|
+
const router = require(file)();
|
|
106
|
+
const route = (method, p) => router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]).route;
|
|
107
|
+
const restore = () => { api.projectSettings.resolveShipMode = saved.resolve; api.projectSettings.set = saved.set; };
|
|
108
|
+
return { route, writes, restore };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test('GET /project-settings/ship-mode is open to any signed-in builder, not gated Metic+', () => {
|
|
112
|
+
const { route, restore } = buildRouter({ resolved: { mode: 'auto', source: 'default', chosen: false, invalid: null } });
|
|
113
|
+
try {
|
|
114
|
+
const names = route('get', '/project-settings/ship-mode').stack.map((l) => l.name);
|
|
115
|
+
assert.equal(names.length, 2, 'requireBuilder + the handler, and no permission gate');
|
|
116
|
+
const write = route('patch', '/project-settings/ship-mode').stack;
|
|
117
|
+
assert.equal(write.length, 3, 'the write carries requireBuilder + the settings permission + the handler');
|
|
118
|
+
} finally { restore(); }
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('the read returns the mode, whether it was chosen, and the sentence to show', async () => {
|
|
122
|
+
const { route, restore } = buildRouter({ resolved: { mode: 'auto', source: 'default', chosen: false, invalid: null } });
|
|
123
|
+
try {
|
|
124
|
+
const res = fakeRes();
|
|
125
|
+
const stack = route('get', '/project-settings/ship-mode').stack;
|
|
126
|
+
await stack[stack.length - 1].handle({}, res);
|
|
127
|
+
assert.equal(res.payload.mode, 'auto');
|
|
128
|
+
assert.equal(res.payload.chosen, false);
|
|
129
|
+
assert.match(res.payload.sentence, /nobody has chosen yet/);
|
|
130
|
+
assert.deepEqual(res.payload.choices.map((c) => c.mode), ['auto', 'manual']);
|
|
131
|
+
assert.match(res.payload.choices[0].label, /recommended/i);
|
|
132
|
+
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
133
|
+
} finally { restore(); }
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('the write refuses an unknown mode and saves a good one, echoing what is now read', async () => {
|
|
137
|
+
const { route, writes, restore } = buildRouter({ resolved: { mode: 'auto', source: 'default', chosen: false, invalid: null } });
|
|
138
|
+
try {
|
|
139
|
+
const stack = route('patch', '/project-settings/ship-mode').stack;
|
|
140
|
+
const handle = stack[stack.length - 1].handle;
|
|
141
|
+
|
|
142
|
+
const bad = fakeRes();
|
|
143
|
+
await handle({ body: { mode: 'sometimes' }, builder: { id: 7 } }, bad);
|
|
144
|
+
assert.equal(bad.code, 'bad_ship_mode');
|
|
145
|
+
assert.equal(writes.length, 0, 'a refused mode writes nothing');
|
|
146
|
+
|
|
147
|
+
const good = fakeRes();
|
|
148
|
+
await handle({ body: { mode: 'Manual' }, builder: { id: 7 } }, good);
|
|
149
|
+
assert.equal(good.code, null);
|
|
150
|
+
assert.deepEqual(writes, [{ key: 'ship.mode', value: 'manual', by: 7 }]);
|
|
151
|
+
assert.equal(good.payload.mode, 'manual');
|
|
152
|
+
assert.equal(good.payload.chosen, true);
|
|
153
|
+
} finally { restore(); }
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ---- the CLI ----------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
test('bongos ship-mode is a registered verb wired to shipping-mode.js', () => {
|
|
159
|
+
const cli = require(path.join(ROOT, 'bin', 'bongos.js'));
|
|
160
|
+
const def = cli.VERBS['ship-mode'];
|
|
161
|
+
assert.ok(def, 'bongos ship-mode must be a registered verb');
|
|
162
|
+
assert.equal(def.script, 'shipping-mode.js');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('the CLI text names the mode and how to switch it', () => {
|
|
166
|
+
const { describe } = require(path.join(ROOT, 'scripts', 'gds', 'shipping-mode.js'));
|
|
167
|
+
const auto = describe({ mode: 'auto', chosen: false, sentence: 'Auto: …' });
|
|
168
|
+
assert.match(auto, /Ship mode: auto \(default — not chosen yet\)/);
|
|
169
|
+
assert.match(auto, /shipping-mode\.js manual/);
|
|
170
|
+
assert.match(describe({ mode: 'manual', chosen: true }), /shipping-mode\.js auto/);
|
|
171
|
+
});
|