@bongos/core 1.19.517 → 1.19.519

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.
@@ -105,7 +105,7 @@ module.exports = function buildMeRouter() {
105
105
  builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
106
106
  recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
107
107
  behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
108
- taskRecommendationCount, boardVotesPending,
108
+ taskRecommendationCount, boardVotesPending, socialMode, mingleIntroductionCount,
109
109
  ] = await Promise.all([
110
110
  db.getBuilderById(req.builder.id),
111
111
  lc().getActiveClaimsForBuilder(req.builder.id),
@@ -205,6 +205,29 @@ module.exports = function buildMeRouter() {
205
205
  return await government.pendingBoardVotesFor(req.builder.id);
206
206
  } catch (_) { return null; }
207
207
  })(),
208
+ // task 1003178 (C08): SOCIAL MODE — the pair of switches C07 resolved
209
+ // (the project enabled mingle AND this builder opted in). It rides /me
210
+ // because the criterion's whole point is that social changes the HALL
211
+ // rather than adding a destination: every page already loads /me, so the
212
+ // shell can stamp the mode on the document and any surface can read it.
213
+ // Same fan-out slot and FAIL-OPEN posture as the reads above — null, not a
214
+ // throw, and the shell treats null as OFF (a mode that cannot be read is
215
+ // not on).
216
+ (async () => {
217
+ try {
218
+ const lifecycle = lc();
219
+ if (!lifecycle || !lifecycle.socialModeFor) return null;
220
+ return await lifecycle.socialModeFor(req.builder.id);
221
+ } catch (_) { return null; }
222
+ })(),
223
+ // The introductions waiting on this builder, for the needs roster.
224
+ (async () => {
225
+ try {
226
+ const lifecycle = lc();
227
+ if (!lifecycle || !lifecycle.openMingleIntroductionsFor) return 0;
228
+ return await lifecycle.openMingleIntroductionsFor(req.builder.id);
229
+ } catch (_) { return 0; }
230
+ })(),
208
231
  ]);
209
232
  // V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
210
233
  // monthly budget. Surfaced here so the CLI (session-start ping) and the hall
@@ -257,6 +280,19 @@ module.exports = function buildMeRouter() {
257
280
  // Resolved by economy (never re-derived); null if economy is disabled.
258
281
  reward: rewardPolicySafe(),
259
282
  est_vs_actual: estVsActual,
283
+ // task 1003178 (C08, goal 1000075): SOCIAL MODE. The owner's directive is
284
+ // that social is "scoped into the ENTIRE builders hall, not a single social
285
+ // tab", and this key is the mechanism: every hall page already loads /me,
286
+ // so the shell stamps the mode on the document and any surface reads it
287
+ // from there. A /social ROUTE is what the criterion explicitly does not
288
+ // accept, so there is deliberately no route to add — the mode travels with
289
+ // the identity the whole hall is already fetching.
290
+ //
291
+ // Shape: { enabled, project_enabled, opted_in, opted_in_at }, both switches
292
+ // reported separately so a surface can say WHICH half is off (C07). null
293
+ // when lifecycle is disabled or the read failed, and the shell treats null
294
+ // as OFF — a mode that cannot be read is not on.
295
+ social: socialMode,
260
296
  unlocked,
261
297
  locked_with_progress: lockedWithProgress,
262
298
  streak: {
@@ -354,6 +390,8 @@ module.exports = function buildMeRouter() {
354
390
  // this builder, read through the same `lifecycle` port so core never
355
391
  // imports the module (ADR 0093).
356
392
  openTaskRecommendations: taskRecommendationCount,
393
+ // task 1003178 (C08): open mingle introductions waiting on this builder.
394
+ openMingleIntroductions: mingleIntroductionCount,
357
395
  }) : [],
358
396
  };
359
397
  // Module→core contribution seam (BV1.R42, ADR 0083). Let each enabled module
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.517'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.519'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,354 @@
1
+ // tests/agents_validate.mjs — the pure agent-definition validator (task 1002487,
2
+ // goal 1000038).
3
+ //
4
+ // Gates criterion `agents-malformed-rejected`: a malformed definition (unknown
5
+ // trigger; missing persona; `trigger_spec.event` not in EVENT_CATALOG; scope
6
+ // exceeding an allowed module) is refused with a clear error, and the validator
7
+ // NEVER throws — the loader logs and skips, it does not crash the boot.
8
+ //
9
+ // No database, no fixtures, no fs. Every input is a literal, because the thing
10
+ // under test is a pure decision.
11
+ //
12
+ // Run: node tests/agents_validate.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const {
19
+ validateAgentDefinition,
20
+ EVENT_CATALOG,
21
+ TRIGGER_TYPES,
22
+ MODEL_TIERS,
23
+ RANK_ORDER,
24
+ SCOPE_RANK_FLOOR,
25
+ } = require('../modules/agents/lib/validate.js');
26
+
27
+ let passed = 0;
28
+ let failed = 0;
29
+ async function test(name, fn) {
30
+ try { await fn(); passed++; console.log(` ok ${name}`); }
31
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
32
+ }
33
+
34
+ // A definition that passes everything, so each case below can break exactly one
35
+ // thing and attribute the refusal to that one thing.
36
+ const OK_DEF = Object.freeze({
37
+ name: 'historian',
38
+ persona: 'Observe the shipped task and report what changed.',
39
+ trigger_type: 'event',
40
+ trigger_spec: { event: 'task.shipped' },
41
+ model_tier: 'default',
42
+ scope_modules: ['agents'],
43
+ });
44
+ const OPTS = Object.freeze({ authorRank: 'archon', allowedModules: ['agents'] });
45
+
46
+ const codes = (r) => r.errors.map((e) => e.code);
47
+ const hasCode = (r, code) => codes(r).includes(code);
48
+
49
+ // --- THE BASELINE -----------------------------------------------------------
50
+
51
+ await test('the exemplar definition validates, and normalizes into a value', () => {
52
+ const r = validateAgentDefinition(OK_DEF, OPTS);
53
+ assert.equal(r.ok, true, `expected ok, got errors: ${JSON.stringify(r.errors)}`);
54
+ assert.deepEqual(r.errors, []);
55
+ assert.equal(r.value.name, 'historian');
56
+ assert.equal(r.value.model_tier, 'default');
57
+ assert.deepEqual(r.value.scope_modules, ['agents']);
58
+ });
59
+
60
+ await test('a failed validation returns NO value — nothing downstream can insert it', () => {
61
+ const r = validateAgentDefinition({ ...OK_DEF, persona: '' }, OPTS);
62
+ assert.equal(r.ok, false);
63
+ assert.equal(r.value, null, 'a rejected definition must not hand back a usable row');
64
+ });
65
+
66
+ // --- THE EVENT CATALOG — the fail-closed rule -------------------------------
67
+
68
+ await test('EVENT_CATALOG is exactly the v1 wired set, and is frozen', () => {
69
+ assert.deepEqual(EVENT_CATALOG, ['task.shipped']);
70
+ assert.equal(Object.isFrozen(EVENT_CATALOG), true, 'a caller must not be able to widen the catalog at runtime');
71
+ });
72
+
73
+ await test('an UNWIRED event name is REFUSED, never silently accepted', () => {
74
+ // The whole point of the catalog: an event nothing dispatches would produce an
75
+ // agent that looks armed and never fires.
76
+ for (const bad of ['task.completed', 'task.shipped ', 'TASK.SHIPPED', 'claim.created', 'task.*']) {
77
+ const r = validateAgentDefinition({ ...OK_DEF, trigger_spec: { event: bad } }, OPTS);
78
+ assert.equal(r.ok, false, `"${bad}" must be refused`);
79
+ assert.ok(hasCode(r, 'trigger_event_unwired'), `"${bad}" → ${JSON.stringify(codes(r))}`);
80
+ }
81
+ });
82
+
83
+ await test('the refusal NAMES the offending event and the catalog', () => {
84
+ const r = validateAgentDefinition({ ...OK_DEF, trigger_spec: { event: 'task.completed' } }, OPTS);
85
+ const msg = r.errors.find((e) => e.code === 'trigger_event_unwired').message;
86
+ assert.ok(msg.includes('task.completed'), `message must name the offender: ${msg}`);
87
+ assert.ok(msg.includes('task.shipped'), `message must name what IS wired: ${msg}`);
88
+ });
89
+
90
+ await test('an event trigger with no event name is refused', () => {
91
+ for (const spec of [{}, { event: '' }, { event: ' ' }, { event: null }, { event: 42 }]) {
92
+ const r = validateAgentDefinition({ ...OK_DEF, trigger_spec: spec }, OPTS);
93
+ assert.equal(r.ok, false, `${JSON.stringify(spec)} must be refused`);
94
+ assert.ok(
95
+ hasCode(r, 'trigger_event_missing') || hasCode(r, 'trigger_event_unwired'),
96
+ `${JSON.stringify(spec)} → ${JSON.stringify(codes(r))}`,
97
+ );
98
+ }
99
+ });
100
+
101
+ await test('an on-demand definition needs no event at all', () => {
102
+ const r = validateAgentDefinition(
103
+ { ...OK_DEF, trigger_type: 'on-demand', trigger_spec: {} }, OPTS,
104
+ );
105
+ assert.equal(r.ok, true, JSON.stringify(r.errors));
106
+ });
107
+
108
+ await test('an on-demand definition carrying an UNWIRED event is still refused', () => {
109
+ // Fails closed on the event NAME wherever it appears. An inert `event:` key on
110
+ // an on-demand agent is either a typo'd trigger_type or dead config; both are
111
+ // bugs the author should be shown rather than have silently accepted.
112
+ const r = validateAgentDefinition(
113
+ { ...OK_DEF, trigger_type: 'on-demand', trigger_spec: { event: 'task.completed' } }, OPTS,
114
+ );
115
+ assert.equal(r.ok, false);
116
+ assert.ok(hasCode(r, 'trigger_event_unwired'));
117
+ });
118
+
119
+ await test('an on-demand definition carrying a WIRED event is accepted', () => {
120
+ const r = validateAgentDefinition(
121
+ { ...OK_DEF, trigger_type: 'on-demand', trigger_spec: { event: 'task.shipped' } }, OPTS,
122
+ );
123
+ assert.equal(r.ok, true, JSON.stringify(r.errors));
124
+ });
125
+
126
+ // --- THE TRIGGER TYPE -------------------------------------------------------
127
+
128
+ await test('TRIGGER_TYPES is the v1 pair the DB CHECK also pins', () => {
129
+ assert.deepEqual(TRIGGER_TYPES, ['event', 'on-demand']);
130
+ });
131
+
132
+ await test('an unknown trigger type is refused', () => {
133
+ for (const bad of ['cron', 'webhook', 'Event', 'on demand', '', null, undefined, 7, {}]) {
134
+ const r = validateAgentDefinition({ ...OK_DEF, trigger_type: bad }, OPTS);
135
+ assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
136
+ assert.ok(hasCode(r, 'trigger_type_unknown'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
137
+ }
138
+ });
139
+
140
+ await test('a non-object trigger_spec is refused rather than coerced', () => {
141
+ for (const bad of ['task.shipped', 42, [], true]) {
142
+ const r = validateAgentDefinition({ ...OK_DEF, trigger_spec: bad }, OPTS);
143
+ assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
144
+ assert.ok(hasCode(r, 'trigger_spec_not_object'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
145
+ }
146
+ });
147
+
148
+ // --- THE PERSONA ------------------------------------------------------------
149
+
150
+ await test('a missing or empty persona is refused', () => {
151
+ for (const bad of [undefined, null, '', ' ', 42, {}]) {
152
+ const r = validateAgentDefinition({ ...OK_DEF, persona: bad }, OPTS);
153
+ assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
154
+ assert.ok(hasCode(r, 'persona_missing'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
155
+ }
156
+ });
157
+
158
+ await test('an oversized persona is refused at the same bound the DB CHECK holds', () => {
159
+ const r = validateAgentDefinition({ ...OK_DEF, persona: 'x'.repeat(32769) }, OPTS);
160
+ assert.equal(r.ok, false);
161
+ assert.ok(hasCode(r, 'persona_too_long'));
162
+ // ...and exactly at the bound it is fine.
163
+ assert.equal(validateAgentDefinition({ ...OK_DEF, persona: 'x'.repeat(32768) }, OPTS).ok, true);
164
+ });
165
+
166
+ // --- THE NAME ---------------------------------------------------------------
167
+
168
+ await test('a name that could not be a filename / URL segment / CLI arg is refused', () => {
169
+ for (const bad of ['Historian', '1historian', 'the historian', 'his/torian', '-historian', '', 'a'.repeat(65)]) {
170
+ const r = validateAgentDefinition({ ...OK_DEF, name: bad }, OPTS);
171
+ assert.equal(r.ok, false, `"${bad}" must be refused`);
172
+ assert.ok(hasCode(r, 'name_invalid'), `"${bad}" → ${JSON.stringify(codes(r))}`);
173
+ }
174
+ assert.equal(validateAgentDefinition({ ...OK_DEF, name: 'a'.repeat(64) }, OPTS).ok, true);
175
+ });
176
+
177
+ // --- THE MODEL TIER ---------------------------------------------------------
178
+
179
+ await test('model_tier defaults to `default` when absent, and rejects an unknown tier', () => {
180
+ assert.deepEqual(MODEL_TIERS, ['default', 'routine']);
181
+ const { model_tier: _omit, ...noTier } = OK_DEF;
182
+ const r = validateAgentDefinition(noTier, OPTS);
183
+ assert.equal(r.ok, true, JSON.stringify(r.errors));
184
+ assert.equal(r.value.model_tier, 'default');
185
+
186
+ // spawn.js's resolveModel() deliberately DEGRADES an unknown tier to the default
187
+ // tier at fire time; that is a runtime safety net, not a licence to author one.
188
+ const bad = validateAgentDefinition({ ...OK_DEF, model_tier: 'main' }, OPTS);
189
+ assert.equal(bad.ok, false);
190
+ assert.ok(hasCode(bad, 'model_tier_unknown'));
191
+ });
192
+
193
+ await test('there is no `main` tier — an agent is a subagent, never the main session', () => {
194
+ assert.equal(MODEL_TIERS.includes('main'), false);
195
+ });
196
+
197
+ // --- THE SCOPE WALL ---------------------------------------------------------
198
+
199
+ await test('scope exceeding the allowed modules is refused, naming the offenders', () => {
200
+ const r = validateAgentDefinition(
201
+ { ...OK_DEF, scope_modules: ['agents', 'government', 'lifecycle'] },
202
+ { authorRank: 'archon', allowedModules: ['agents'] },
203
+ );
204
+ assert.equal(r.ok, false);
205
+ assert.ok(hasCode(r, 'scope_out_of_bounds'));
206
+ const msg = r.errors.find((e) => e.code === 'scope_out_of_bounds').message;
207
+ assert.ok(msg.includes('government') && msg.includes('lifecycle'), `must name the offenders: ${msg}`);
208
+ assert.ok(!msg.includes("'agents'"), 'must not accuse the module that WAS allowed');
209
+ });
210
+
211
+ await test('scope FAILS CLOSED when the caller supplies no allowed-module set', () => {
212
+ // The dangerous default. An empty allowlist must mean "nothing is in scope",
213
+ // never "everything is" — the same rule goal-scope-check's empty_scope holds.
214
+ for (const opts of [{ authorRank: 'archon' }, { authorRank: 'archon', allowedModules: [] }, undefined]) {
215
+ const r = validateAgentDefinition(OK_DEF, opts);
216
+ assert.equal(r.ok, false, `${JSON.stringify(opts)} must fail closed`);
217
+ assert.ok(hasCode(r, 'scope_out_of_bounds'), `${JSON.stringify(opts)} → ${JSON.stringify(codes(r))}`);
218
+ }
219
+ });
220
+
221
+ await test('an empty scope_modules is allowed — it grants nothing', () => {
222
+ const r = validateAgentDefinition({ ...OK_DEF, scope_modules: [] }, { authorRank: 'archon', allowedModules: [] });
223
+ assert.equal(r.ok, true, JSON.stringify(r.errors));
224
+ assert.deepEqual(r.value.scope_modules, []);
225
+ });
226
+
227
+ await test('a non-array / non-string scope is refused rather than coerced', () => {
228
+ for (const bad of ['agents', 42, {}, ['agents', 7], ['agents', '']]) {
229
+ const r = validateAgentDefinition({ ...OK_DEF, scope_modules: bad }, OPTS);
230
+ assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
231
+ assert.ok(hasCode(r, 'scope_modules_invalid'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
232
+ }
233
+ });
234
+
235
+ await test('a scope_path that escapes the tree is refused', () => {
236
+ for (const bad of ['../../etc/passwd', '/etc/passwd', 'modules/../../..', 'C:\\Windows']) {
237
+ const r = validateAgentDefinition({ ...OK_DEF, scope_paths: [bad] }, OPTS);
238
+ assert.equal(r.ok, false, `"${bad}" must be refused`);
239
+ assert.ok(hasCode(r, 'scope_paths_invalid'), `"${bad}" → ${JSON.stringify(codes(r))}`);
240
+ }
241
+ assert.equal(validateAgentDefinition({ ...OK_DEF, scope_paths: ['modules/agents/'] }, OPTS).ok, true);
242
+ });
243
+
244
+ // --- THE RANK WALL (ADR 0016 — authority is the author's LIVE rank) ---------
245
+
246
+ await test('RANK_ORDER is the ladder low→high, and the scope floor is Metic', () => {
247
+ assert.deepEqual(RANK_ORDER, ['xenos', 'thetes', 'metic', 'archon']);
248
+ assert.equal(SCOPE_RANK_FLOOR, 'metic');
249
+ });
250
+
251
+ await test('a sub-Metic author may not scope an agent onto a PROTECTED module', () => {
252
+ const opts = { allowedModules: ['agents', 'government'], protectedModules: ['government'] };
253
+ for (const rank of ['xenos', 'thetes']) {
254
+ const r = validateAgentDefinition({ ...OK_DEF, scope_modules: ['government'] }, { ...opts, authorRank: rank });
255
+ assert.equal(r.ok, false, `${rank} must not reach a protected module`);
256
+ assert.ok(hasCode(r, 'scope_protected_rank'), `${rank} → ${JSON.stringify(codes(r))}`);
257
+ }
258
+ // Metic+ clear the floor.
259
+ for (const rank of ['metic', 'archon']) {
260
+ const r = validateAgentDefinition({ ...OK_DEF, scope_modules: ['government'] }, { ...opts, authorRank: rank });
261
+ assert.equal(r.ok, true, `${rank} should clear the floor: ${JSON.stringify(r.errors)}`);
262
+ }
263
+ });
264
+
265
+ await test('an UNKNOWN author rank fails CLOSED against a protected module', () => {
266
+ // A file cannot grant authority (ADR 0016). If the caller could not name a live
267
+ // rank, the definition has no authority to reach a protected module.
268
+ const opts = { allowedModules: ['government'], protectedModules: ['government'] };
269
+ for (const rank of [undefined, null, '', 'admin', 'ARCHON']) {
270
+ const r = validateAgentDefinition({ ...OK_DEF, scope_modules: ['government'] }, { ...opts, authorRank: rank });
271
+ assert.equal(r.ok, false, `${JSON.stringify(rank)} must fail closed`);
272
+ assert.ok(hasCode(r, 'scope_protected_rank'), `${JSON.stringify(rank)} → ${JSON.stringify(codes(r))}`);
273
+ }
274
+ });
275
+
276
+ await test('an unknown rank is NOT fatal when no protected module is in scope', () => {
277
+ // The rank wall guards protected scope only; an ordinary agent does not need a
278
+ // resolved rank to be structurally valid.
279
+ const r = validateAgentDefinition(OK_DEF, { authorRank: 'nonsense', allowedModules: ['agents'] });
280
+ assert.equal(r.ok, true, JSON.stringify(r.errors));
281
+ });
282
+
283
+ // --- NEVER CRASH THE BOOT ---------------------------------------------------
284
+
285
+ await test('a non-object definition is refused, not thrown on', () => {
286
+ for (const bad of [null, undefined, 'historian', 42, [], true]) {
287
+ let r;
288
+ assert.doesNotThrow(() => { r = validateAgentDefinition(bad, OPTS); }, `${JSON.stringify(bad)} threw`);
289
+ assert.equal(r.ok, false);
290
+ assert.ok(hasCode(r, 'not_an_object'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
291
+ }
292
+ });
293
+
294
+ await test('a CIRCULAR trigger_spec is refused, not thrown on', () => {
295
+ // The size check is over the serialized spec, and JSON.stringify THROWS on a
296
+ // cycle. A definition file cannot express one, but a db-sourced object can —
297
+ // and this validator is the thing standing between that and the boot.
298
+ const spec = { event: 'task.shipped' };
299
+ spec.self = spec;
300
+ let r;
301
+ assert.doesNotThrow(() => { r = validateAgentDefinition({ ...OK_DEF, trigger_spec: spec }, OPTS); });
302
+ assert.equal(r.ok, false);
303
+ assert.ok(hasCode(r, 'trigger_spec_unserializable'), JSON.stringify(codes(r)));
304
+ });
305
+
306
+ await test('an oversized trigger_spec is refused at the DB CHECK bound', () => {
307
+ const r = validateAgentDefinition(
308
+ { ...OK_DEF, trigger_spec: { event: 'task.shipped', pad: 'x'.repeat(9000) } }, OPTS,
309
+ );
310
+ assert.equal(r.ok, false);
311
+ assert.ok(hasCode(r, 'trigger_spec_too_long'));
312
+ });
313
+
314
+ await test('a hostile getter cannot escape the validator', () => {
315
+ // trigger_spec is PARAMETERS, never payload — and a throwing accessor must
316
+ // become a refusal, not an exception into the loader.
317
+ const hostile = { name: 'historian', persona: 'x', trigger_type: 'event' };
318
+ Object.defineProperty(hostile, 'trigger_spec', { get() { throw new Error('boom'); }, enumerable: true });
319
+ let r;
320
+ assert.doesNotThrow(() => { r = validateAgentDefinition(hostile, OPTS); });
321
+ assert.equal(r.ok, false);
322
+ });
323
+
324
+ await test('EVERY error carries a code, a field and a human message', () => {
325
+ const r = validateAgentDefinition(
326
+ { name: 'BAD NAME', trigger_type: 'cron', trigger_spec: 'nope', scope_modules: ['government'] },
327
+ { authorRank: 'xenos', allowedModules: ['agents'] },
328
+ );
329
+ assert.equal(r.ok, false);
330
+ assert.ok(r.errors.length >= 4, `expected the whole list, got ${JSON.stringify(codes(r))}`);
331
+ for (const e of r.errors) {
332
+ assert.equal(typeof e.code, 'string', JSON.stringify(e));
333
+ assert.equal(typeof e.field, 'string', JSON.stringify(e));
334
+ assert.ok(e.message && e.message.length > 10, `message too thin: ${JSON.stringify(e)}`);
335
+ }
336
+ });
337
+
338
+ await test('validation reports ALL faults at once, not just the first', () => {
339
+ // An author fixing a definition file should not have to re-run four times.
340
+ const r = validateAgentDefinition({ name: 'X', persona: '', trigger_type: 'cron' }, OPTS);
341
+ for (const code of ['name_invalid', 'persona_missing', 'trigger_type_unknown']) {
342
+ assert.ok(hasCode(r, code), `${code} missing from ${JSON.stringify(codes(r))}`);
343
+ }
344
+ });
345
+
346
+ await test('the validator never MUTATES the definition it was handed', () => {
347
+ const def = { ...OK_DEF, trigger_spec: { event: 'task.shipped' } };
348
+ const before = JSON.stringify(def);
349
+ validateAgentDefinition(def, OPTS);
350
+ assert.equal(JSON.stringify(def), before);
351
+ });
352
+
353
+ console.log(`\nagents_validate: ${passed} passed, ${failed} failed`);
354
+ if (failed > 0) process.exit(1);
package/tests/mingle.mjs CHANGED
@@ -283,6 +283,32 @@ test('a declined pair may be introduced again — a decline is not a permanent s
283
283
  assert.equal(mingle.planPairs(eligible, { excludePairs: new Set() }).length, 1);
284
284
  });
285
285
 
286
+ test('the roster COUNT sees both sides of a pair, and only open ones', async () => {
287
+ // task 1003178 (C08) added countOpenPairsFor for the needs roster, and the
288
+ // grader was right that it shipped untested while every other DB function here
289
+ // is driven against the recording pool. A count is exactly the kind of query
290
+ // that goes wrong quietly: miss a side of the pair and half the hall never
291
+ // learns it has an introduction waiting.
292
+ const pool = {
293
+ queries: [],
294
+ async query(sql, params) {
295
+ this.queries.push({ sql, params });
296
+ return { rows: [{ n: 3 }] };
297
+ },
298
+ };
299
+ const n = await mingle.countOpenPairsFor(42, { pool });
300
+ assert.equal(n, 3);
301
+ const { sql, params } = pool.queries[0];
302
+ assert.match(sql, /builder_lo = \$1 OR builder_hi = \$1/, 'either side of the pair counts');
303
+ assert.match(sql, /status = 'proposed'/, 'a settled introduction is not waiting on anyone');
304
+ assert.deepEqual(params, [42]);
305
+ });
306
+
307
+ test('the count answers 0 rather than undefined on an empty read', async () => {
308
+ const pool = { async query() { return { rows: [] }; } };
309
+ assert.equal(await mingle.countOpenPairsFor(42, { pool }), 0);
310
+ });
311
+
286
312
  test('the exclude set is built from OPEN pairs only, never from declines', async () => {
287
313
  const pool = recordingPool({ openPairs: [['1', '2']] });
288
314
  await mingle.openPairKeys({ pool });
@@ -116,6 +116,17 @@ const STATIC_LADDER_FILES = [
116
116
  // which holds it against catalog.RANK_ORDER and holds the whole browser parser
117
117
  // equal to the server one.
118
118
  'modules/hall-ui/public/government.js',
119
+ // task 1002487: the pure agent-definition validator applies the ADR 0043
120
+ // sub-Metic floor to an agent's declared scope, so it needs the ladder to
121
+ // compare against. It is one of the "dependency-free" cases this array exists
122
+ // for, and for a sharper reason than the browser statics above: its whole
123
+ // contract is to be checkable with NO database, and the doorway it would
124
+ // otherwise import (src/module-api.js) eagerly requires ./bongos/pool at load.
125
+ // Deriving the ladder from api.LIVE_RANK_LADDER would therefore drag a
126
+ // Postgres pool into a validator that must run in a bare unit test — so the
127
+ // copy is pinned here instead, which is what makes it provable rather than
128
+ // merely allowed.
129
+ 'modules/agents/lib/validate.js',
119
130
  ];
120
131
  test('static ladder copies carry the exact canonical ladder', () => {
121
132
  for (const rel of STATIC_LADDER_FILES) {
@@ -0,0 +1,181 @@
1
+ // tests/social_mode.mjs — with social on, the HALL changes; it does not gain a
2
+ // tab (task 1003178 / C08, goal 1000075; criterion collab-hall-becomes-a-mode).
3
+ //
4
+ // The criterion is unusual in that it states its own DISQUALIFIER: "a /social
5
+ // route with the same hall around it does NOT satisfy it". So the first block
6
+ // below tests the absence — no route, no nav item, no page file — and the rest
7
+ // tests that existing surfaces read differently instead.
8
+ //
9
+ // The owner's directive, verbatim in the goal: social "is certainly something
10
+ // that should be scoped into the ENTIRE builders hall, not a single social tab".
11
+ import { strict as assert } from 'node:assert';
12
+ import { test } from 'node:test';
13
+ import { createRequire } from 'node:module';
14
+ import fs from 'node:fs';
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
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
21
+
22
+ const SHELL = read('modules', 'hall-ui', 'public', 'shell.js');
23
+ const HALL_RENDER = read('modules', 'hall-ui', 'public', 'hall-render.js');
24
+ const COLLAB_JS = read('modules', 'hall-ui', 'public', 'collab.js');
25
+ const COLLAB_HTML = read('modules', 'hall-ui', 'public', 'collab.html');
26
+ const ME = read('src', 'bongos', 'routes', 'me.js');
27
+ const NEEDS = read('modules', 'builder-settings', 'builder-needs.js');
28
+ const needs = require(path.join(ROOT, 'modules', 'builder-settings', 'builder-needs.js'));
29
+
30
+ // ── 1. THE DISQUALIFIER: social is not a destination ────────────────────────
31
+
32
+ test('THE DISQUALIFIER: there is no /social page in the hall', () => {
33
+ const hall = fs.readdirSync(path.join(ROOT, 'modules', 'hall-ui', 'public'));
34
+ for (const f of hall) {
35
+ assert.ok(!/^social\.(html|js|css)$/.test(f),
36
+ `${f} exists — a social PAGE is exactly what this criterion refuses`);
37
+ }
38
+ });
39
+
40
+ test('THE DISQUALIFIER: no /social route is served, and no page gate names one', () => {
41
+ const serve = read('src', 'bongos', 'serve-internal.js');
42
+ assert.ok(!/\/social\b/.test(serve), 'serve-internal routes nothing at /social');
43
+ const oracle = read('tests', 'hall_page_gate_map.mjs');
44
+ assert.ok(!/'\/builders\/social'/.test(oracle), 'the page-gate oracle has no /social row');
45
+ });
46
+
47
+ test('THE DISQUALIFIER: social adds no nav item', () => {
48
+ // A nav entry would BE the tab. The whole mechanism is an attribute on the
49
+ // document, so there is nothing to link to.
50
+ //
51
+ // The end marker is NAV_COLLAPSE_KEY, which follows the model. An earlier
52
+ // draft sliced to `function icon(` — which is DEFINED ABOVE NAV, so the slice
53
+ // returned an empty string and the assertion passed over nothing. It only
54
+ // surfaced because adding a real Social tab as a mutation did not red it.
55
+ const start = SHELL.indexOf('var NAV = [');
56
+ const end = SHELL.indexOf('var NAV_COLLAPSE_KEY');
57
+ assert.ok(start > -1 && end > start, 'the NAV model was actually located');
58
+ const nav = SHELL.slice(start, end);
59
+ assert.ok(nav.includes("id: 'roster'"), 'and the slice really holds the nav items');
60
+ assert.ok(!/social/i.test(nav), 'the nav model must not mention social');
61
+ });
62
+
63
+ // ── 2. SOCIAL IS A MODE, STAMPED ON THE DOCUMENT ────────────────────────────
64
+
65
+ test('the shell stamps the mode on the document root, beside data-theme', () => {
66
+ // An attribute on <html> is what lets ANY surface — a stylesheet, a page
67
+ // script, a widget — read the mode without a route or a second fetch. That is
68
+ // the structural difference between a mode and a page.
69
+ assert.match(SHELL, /function applySocial\(social\)/);
70
+ assert.match(SHELL, /root\.dataset\.social = 'on'/);
71
+ assert.match(SHELL, /delete root\.dataset\.social/);
72
+ });
73
+
74
+ test('OFF is the default and the fallback — null is not on', () => {
75
+ // /me.social is null when lifecycle is disabled or the read failed. A mode
76
+ // that cannot be read is not on.
77
+ const fn = SHELL.slice(SHELL.indexOf('function applySocial'));
78
+ const body = fn.slice(0, fn.indexOf('\n }'));
79
+ assert.match(body, /social && social\.enabled === true/,
80
+ 'only an explicit enabled:true turns the mode on');
81
+ });
82
+
83
+ test('BOTH switches decide it, and the client never re-derives them', () => {
84
+ // C07 resolved the pair (project enabled AND builder opted in) server-side.
85
+ // A client that recomputed the mode could disagree with the rest of the hall.
86
+ assert.ok(!/opted_in|project_enabled/.test(SHELL),
87
+ 'the shell reads `enabled`, never the two switches it came from');
88
+ assert.match(ME, /socialModeFor/);
89
+ assert.match(ME, /social: socialMode/);
90
+ });
91
+
92
+ test('the mode rides /me, which every hall page already fetches', () => {
93
+ // This is why no new route is needed — and why one would be the wrong shape.
94
+ assert.match(SHELL, /social: me\.social \|\| null/);
95
+ assert.match(HALL_RENDER, /social: \(ctx\.lastMeState && ctx\.lastMeState\.social\) \|\| null/);
96
+ });
97
+
98
+ test('the index passes the mode too — the busiest page is not the one it misses', () => {
99
+ // renderNav takes (builder, onboarding) and is called from five sites, so the
100
+ // value must come off the live ctx getter rather than a local `me`.
101
+ const fn = HALL_RENDER.slice(HALL_RENDER.indexOf('function renderNav('));
102
+ const body = fn.slice(0, fn.indexOf('\n }'));
103
+ assert.ok(!/\bme\?\./.test(body), 'renderNav has no `me` in scope — reading one would throw');
104
+ });
105
+
106
+ test('a social read can never break a profile', () => {
107
+ // /me is the session-open hot path. Same fail-open posture as the C01/C03
108
+ // reads it sits beside.
109
+ const slot = ME.slice(ME.indexOf('socialModeFor') - 600, ME.indexOf('socialModeFor') + 200);
110
+ assert.match(slot, /catch \(_\) \{ return null; \}/);
111
+ });
112
+
113
+ // ── 3. EXISTING SURFACES READ DIFFERENTLY ───────────────────────────────────
114
+
115
+ test('HOME changes: an introduction becomes a need in the roster', () => {
116
+ // The needs roster is how a collab feature reaches the whole hall — it is
117
+ // where C01 and C03 already live, and it renders on Home's Standing card and
118
+ // in the CLI nudge without either learning about mingle.
119
+ const roster = needs.computeNeeds({ openMingleIntroductions: 2 }).items;
120
+ const row = roster.find((n) => n.id === 'mingle_introductions');
121
+ assert.ok(row, 'the need is produced');
122
+ assert.equal(row.state, 'action_needed');
123
+ assert.match(row.action.href, /\/#\/collab/, 'it points at an EXISTING surface, not a new one');
124
+ });
125
+
126
+ test('no introductions means no row — the roster is not padded', () => {
127
+ assert.equal(needs.computeNeeds({ openMingleIntroductions: 0 }).items.find((n) => n.id === 'mingle_introductions'), undefined);
128
+ assert.equal(needs.computeNeeds({}).items.find((n) => n.id === 'mingle_introductions'), undefined);
129
+ });
130
+
131
+ test('the invitation never pressures — declining is free, so the copy says so', () => {
132
+ // Mingle is declinable without penalty (C07, ADR 0187 §5). Wording that
133
+ // pressured the reader would BE the penalty the decision forbids — the same
134
+ // argument the recommendation need makes, and it matters more for a social
135
+ // invitation than for a task.
136
+ const row = needs.computeNeeds({ openMingleIntroductions: 1 }).items;
137
+ const n = row.find((x) => x.id === 'mingle_introductions');
138
+ assert.equal(n.severity, 'info', 'info, never a demand');
139
+ assert.match(n.message, /costs nothing/);
140
+ assert.match(n.message, /does not change how often you are paired/,
141
+ 'and it names the no-future-weight half, which is the part a reader would doubt');
142
+ });
143
+
144
+ test('COLLAB changes: the introductions panel is revealed BY THE MODE', () => {
145
+ // Not by a rank, not by a route, not by a second fetch — by the document
146
+ // attribute the shell stamped. That is what "the surface reads differently"
147
+ // means in practice.
148
+ assert.match(COLLAB_JS, /document\.documentElement\.dataset\.social === 'on'/);
149
+ assert.match(COLLAB_HTML, /id="cb-mingle-section"[^>]*hidden/);
150
+ });
151
+
152
+ test('with social OFF the page is exactly what it was, and costs nothing extra', () => {
153
+ // A page that fetched anyway would make "social is off" cost a request on
154
+ // every load, and a panel that rendered empty would still be a visible change.
155
+ const fn = COLLAB_JS.slice(COLLAB_JS.indexOf('async function loadMingle'));
156
+ const body = fn.slice(0, fn.indexOf('\n }'));
157
+ const guard = body.indexOf('if (!socialOn())');
158
+ assert.ok(guard > -1, 'the mode is checked');
159
+ assert.ok(guard < body.indexOf("api.request('GET', '/mingle')"),
160
+ 'and it is checked BEFORE anything is fetched');
161
+ });
162
+
163
+ test('the introductions read is best-effort — it cannot take the page down', () => {
164
+ // Help requests ARE the Collab page; the social half is additive.
165
+ assert.match(COLLAB_JS, /try \{ await loadMingle\(\); \} catch \(_\)/);
166
+ });
167
+
168
+ test('the row is written from the VIEWER\'s side, without a second /me', () => {
169
+ // A pair carries builder_lo/builder_hi with no marker for "you", so the route
170
+ // says which side the caller is rather than making the page fetch to find out.
171
+ const routes = read('modules', 'lifecycle', 'routes', 'mingle.js');
172
+ assert.match(routes, /you: String\(req\.builder\.id\)/);
173
+ assert.match(COLLAB_JS, /r\.data\.you/);
174
+ assert.match(COLLAB_JS, /function otherOf\(pair, meId\)/);
175
+ });
176
+
177
+ test('answering an introduction reads neutrally either way', () => {
178
+ // "Declined" must not read as a failure. A disappointed toast would be the
179
+ // penalty C07 forbids, delivered by the UI instead of the schema.
180
+ assert.match(COLLAB_JS, /Not now\. Nothing changes for you\./);
181
+ });