@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.
- package/.bongos-core.json +41 -26
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +2 -0
- package/docs/copy-inventory.md +117 -112
- package/docs/copy-registry.json +168 -123
- package/docs/module-api-changelog.md +4 -0
- package/modules/agents/lib/validate.js +306 -0
- package/modules/builder-settings/builder-needs.js +28 -2
- package/modules/hall-ui/public/collab.html +19 -0
- package/modules/hall-ui/public/collab.js +78 -0
- package/modules/hall-ui/public/hall-render.js +6 -0
- package/modules/hall-ui/public/shell.js +30 -1
- package/modules/lifecycle/lifecycle.js +8 -0
- package/modules/lifecycle/mingle.js +17 -1
- package/modules/lifecycle/routes/mingle.js +5 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/me.js +39 -1
- package/src/module-api.js +1 -1
- package/tests/agents_validate.mjs +354 -0
- package/tests/mingle.mjs +26 -0
- package/tests/rank_tier_single_source.mjs +11 -0
- package/tests/social_mode.mjs +181 -0
|
@@ -1471,5 +1471,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1471
1471
|
landed since 1.19.515 with no explicit bump. run 33798954769. (task 1002620)
|
|
1472
1472
|
1.19.517 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1473
1473
|
landed since 1.19.516 with no explicit bump. run 33799755382. (task 1002620)
|
|
1474
|
+
1.19.518 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1475
|
+
landed since 1.19.517 with no explicit bump. run 33801792364. (task 1002620)
|
|
1476
|
+
1.19.519 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1477
|
+
landed since 1.19.518 with no explicit bump. run 33802471495. (task 1002620)
|
|
1474
1478
|
---------------------------------------------------------------------------
|
|
1475
1479
|
```
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/agents/lib/validate.js — the pure agent-definition validator (task
|
|
4
|
+
// 1002487, goal 1000038 Phase 1; criterion `agents-malformed-rejected`).
|
|
5
|
+
//
|
|
6
|
+
// Answers one question with no I/O: "is this object a definition this instance is
|
|
7
|
+
// willing to register?" Given a candidate definition and the caller's context
|
|
8
|
+
// (the author's LIVE rank, the module keys in bounds), it returns a verdict plus
|
|
9
|
+
// the full list of what is wrong with it.
|
|
10
|
+
//
|
|
11
|
+
// WHY IT IS PURE, AND WHY THAT IS THE POINT. Definitions arrive from two places —
|
|
12
|
+
// a committed `.claude/agents/*.md` file reconciled at deploy (agents-sync, task
|
|
13
|
+
// 1002490) and an instance-authored DB row. Both paths need the SAME answer, and
|
|
14
|
+
// the answer must be checkable without a database, a model call, or a booted
|
|
15
|
+
// server. So this file requires NOTHING: not the module doorway, not a sibling,
|
|
16
|
+
// not even a node builtin. That also keeps it trivially inside the ADR 0083
|
|
17
|
+
// boundary — `modules/**` may import only `src/module-api.js`, and importing
|
|
18
|
+
// nothing clears that bar by construction.
|
|
19
|
+
//
|
|
20
|
+
// IT MUST NEVER THROW. The loader's contract is log-and-skip: one malformed
|
|
21
|
+
// definition makes the registry smaller, never makes the boot fail. Every access
|
|
22
|
+
// below is therefore defensive — a hostile getter, a circular `trigger_spec`, a
|
|
23
|
+
// frozen object, `null`: all become a returned error, none become an exception
|
|
24
|
+
// into the caller. The tests assert this directly (tests/agents_validate.mjs).
|
|
25
|
+
//
|
|
26
|
+
// FAIL CLOSED IS THE HOUSE RULE, in three specific places a future reader will be
|
|
27
|
+
// tempted to relax:
|
|
28
|
+
// 1. EVENT_CATALOG is the WIRED set, not the imaginable set. v1 dispatches
|
|
29
|
+
// exactly `task.shipped`. An unwired event name is refused rather than
|
|
30
|
+
// stored, because a definition carrying one looks armed in the registry and
|
|
31
|
+
// can never fire — the worst of both readings.
|
|
32
|
+
// 2. An EMPTY allowed-module set means NOTHING is in scope, never everything.
|
|
33
|
+
// A caller that forgets to pass `allowedModules` gets refusals, not a
|
|
34
|
+
// wide-open agent. Same rule, same reason as goal-scope-check's `empty_scope`.
|
|
35
|
+
// 3. An UNRESOLVED author rank has no authority over a protected module. A file
|
|
36
|
+
// cannot grant authority (ADR 0016), so "we could not determine the rank" is
|
|
37
|
+
// treated as "not enough rank", not as "skip the check".
|
|
38
|
+
//
|
|
39
|
+
// THE DB HOLDS THESE BOUNDS TOO, DELIBERATELY. Every limit here mirrors a CHECK
|
|
40
|
+
// constraint in agents_001_definitions.sql. That duplication is defense in depth
|
|
41
|
+
// and is meant to stay: the constraint is what stops a row that skipped this
|
|
42
|
+
// validator, and this validator is what turns a would-be constraint violation
|
|
43
|
+
// into a readable message naming the offending field — an insert that throws at
|
|
44
|
+
// boot is precisely the crash the log-and-skip contract exists to prevent.
|
|
45
|
+
|
|
46
|
+
// The WIRED event set for v1. Widening this is a deliberate act that must land
|
|
47
|
+
// together with something that actually dispatches the new event.
|
|
48
|
+
const EVENT_CATALOG = Object.freeze(['task.shipped']);
|
|
49
|
+
|
|
50
|
+
// Mirrors agents_definitions_trigger_type_chk. 'event' consumes the catalog above;
|
|
51
|
+
// 'on-demand' is the historian shape (a builder asks, the agent answers).
|
|
52
|
+
const TRIGGER_TYPES = Object.freeze(['event', 'on-demand']);
|
|
53
|
+
|
|
54
|
+
// Mirrors agents_definitions_model_tier_chk. No 'main' tier by design — an agent
|
|
55
|
+
// is a subagent, never the main session.
|
|
56
|
+
const MODEL_TIERS = Object.freeze(['default', 'routine']);
|
|
57
|
+
|
|
58
|
+
// The rank ladder, low → high. Mirrors government/catalog.js RANK_ORDER and the
|
|
59
|
+
// agents_definitions_author_rank_chk vocabulary. Duplicated rather than imported
|
|
60
|
+
// because the module boundary forbids reaching into core (ADR 0083) — and it is a
|
|
61
|
+
// closed, rarely-changing vocabulary the DB constraint pins independently.
|
|
62
|
+
const RANK_ORDER = Object.freeze(['xenos', 'thetes', 'metic', 'archon']);
|
|
63
|
+
|
|
64
|
+
// Sub-Metic builders stay out of the permission/methodology core (ADR 0043), and
|
|
65
|
+
// that applies to an agent they author just as it applies to a task they claim.
|
|
66
|
+
const SCOPE_RANK_FLOOR = 'metic';
|
|
67
|
+
|
|
68
|
+
// Filename / URL-segment / CLI-argument safe — the same shape a module key takes,
|
|
69
|
+
// mirroring agents_definitions_name_fmt_chk.
|
|
70
|
+
const NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
71
|
+
const NAME_MAX = 64;
|
|
72
|
+
// Mirrors agents_definitions_persona_size_chk / _trigger_spec_size_chk. A persona
|
|
73
|
+
// is a prompt sent to a paid model on every fire, so an unbounded one is both a
|
|
74
|
+
// cost and a blast radius.
|
|
75
|
+
const PERSONA_MAX = 32768;
|
|
76
|
+
const TRIGGER_SPEC_MAX = 8192;
|
|
77
|
+
|
|
78
|
+
// A plain data object — not an array, not null, not a boxed primitive. Arrays are
|
|
79
|
+
// excluded explicitly because `typeof [] === 'object'` would otherwise let a list
|
|
80
|
+
// masquerade as a spec.
|
|
81
|
+
function isPlainObject(v) {
|
|
82
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isNonEmptyString(v) {
|
|
86
|
+
return typeof v === 'string' && v.trim().length > 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Read one property without trusting the object. A definition can arrive as a
|
|
90
|
+
// db-sourced object with an accessor on it; a throwing getter must become a
|
|
91
|
+
// refusal, not an exception into the loader.
|
|
92
|
+
function safeGet(obj, key) {
|
|
93
|
+
try { return obj[key]; } catch (_) { return undefined; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A scope path must stay inside the tree. Rejects absolute POSIX and Windows
|
|
97
|
+
// paths, and any `..` segment on either separator — the escape a scope wall
|
|
98
|
+
// exists to stop.
|
|
99
|
+
function isSafeScopePath(p) {
|
|
100
|
+
if (!isNonEmptyString(p)) return false;
|
|
101
|
+
if (p.startsWith('/') || p.startsWith('\\')) return false;
|
|
102
|
+
if (/^[a-zA-Z]:/.test(p)) return false;
|
|
103
|
+
return !p.split(/[\\/]/).includes('..');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate one candidate agent definition. Pure, total, and never throws.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} obj the candidate definition (file frontmatter or a db row).
|
|
110
|
+
* @param {object} [opts]
|
|
111
|
+
* @param {string|null} [opts.authorRank] the author's LIVE DB rank (ADR 0016).
|
|
112
|
+
* Never read from the definition itself.
|
|
113
|
+
* @param {string[]} [opts.allowedModules] module keys this definition may be
|
|
114
|
+
* scoped to. Empty ⇒ nothing is in scope.
|
|
115
|
+
* @param {string[]} [opts.protectedModules] the subset of those that require Metic+.
|
|
116
|
+
* @returns {{ok: boolean, errors: Array<{code: string, field: string, message: string}>, value: object|null}}
|
|
117
|
+
* `value` is the normalized definition (defaults applied) on success, and
|
|
118
|
+
* `null` on any failure — a rejected definition must never hand back a row
|
|
119
|
+
* something downstream could insert.
|
|
120
|
+
*/
|
|
121
|
+
function validateAgentDefinition(obj, opts = {}) {
|
|
122
|
+
const errors = [];
|
|
123
|
+
const E = (code, field, message) => errors.push({ code, field, message });
|
|
124
|
+
|
|
125
|
+
if (!isPlainObject(obj)) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
errors: [{
|
|
129
|
+
code: 'not_an_object',
|
|
130
|
+
field: '(root)',
|
|
131
|
+
message: `An agent definition must be an object; received ${obj === null ? 'null' : Array.isArray(obj) ? 'an array' : typeof obj}.`,
|
|
132
|
+
}],
|
|
133
|
+
value: null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Read through safeGet for the same reason the definition is: a caller may hand
|
|
138
|
+
// us a proxy or an accessor-bearing object, and the never-throws contract covers
|
|
139
|
+
// the whole call, not just the definition half of it.
|
|
140
|
+
const options = isPlainObject(opts) ? opts : {};
|
|
141
|
+
const authorRank = safeGet(options, 'authorRank');
|
|
142
|
+
const rawAllowed = safeGet(options, 'allowedModules');
|
|
143
|
+
const rawProtected = safeGet(options, 'protectedModules');
|
|
144
|
+
const allowedModules = Array.isArray(rawAllowed) ? rawAllowed : [];
|
|
145
|
+
const protectedModules = Array.isArray(rawProtected) ? rawProtected : [];
|
|
146
|
+
|
|
147
|
+
// --- name ----------------------------------------------------------------
|
|
148
|
+
const name = safeGet(obj, 'name');
|
|
149
|
+
if (!isNonEmptyString(name) || !NAME_RE.test(name) || name.length > NAME_MAX) {
|
|
150
|
+
E('name_invalid', 'name',
|
|
151
|
+
`name: ${JSON.stringify(name)} must be lowercase kebab-case matching ${NAME_RE} and at most ${NAME_MAX} characters — it is used as a filename, a URL segment and a CLI argument.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- persona --------------------------------------------------------------
|
|
155
|
+
const persona = safeGet(obj, 'persona');
|
|
156
|
+
if (!isNonEmptyString(persona)) {
|
|
157
|
+
E('persona_missing', 'persona',
|
|
158
|
+
'persona: required — an agent with no role prompt has nothing to run. Give it a non-empty string saying what it observes and reports.');
|
|
159
|
+
} else if (persona.length > PERSONA_MAX) {
|
|
160
|
+
E('persona_too_long', 'persona',
|
|
161
|
+
`persona: ${persona.length} characters exceeds the ${PERSONA_MAX} limit — the persona is sent to a paid model on every fire.`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- trigger type ---------------------------------------------------------
|
|
165
|
+
const triggerType = safeGet(obj, 'trigger_type');
|
|
166
|
+
const triggerTypeOk = typeof triggerType === 'string' && TRIGGER_TYPES.includes(triggerType);
|
|
167
|
+
if (!triggerTypeOk) {
|
|
168
|
+
E('trigger_type_unknown', 'trigger_type',
|
|
169
|
+
`trigger_type: ${JSON.stringify(triggerType)} is not a wired trigger type. v1 wires exactly: ${TRIGGER_TYPES.join(', ')}.`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- trigger spec ---------------------------------------------------------
|
|
173
|
+
// Defaults to {} when absent, matching the column default. Present-but-wrong is
|
|
174
|
+
// a refusal; absent is a legitimate on-demand definition.
|
|
175
|
+
const rawSpec = safeGet(obj, 'trigger_spec');
|
|
176
|
+
let spec = null;
|
|
177
|
+
if (rawSpec === undefined || rawSpec === null) {
|
|
178
|
+
spec = {};
|
|
179
|
+
} else if (!isPlainObject(rawSpec)) {
|
|
180
|
+
E('trigger_spec_not_object', 'trigger_spec',
|
|
181
|
+
`trigger_spec: must be an object of parameters; received ${Array.isArray(rawSpec) ? 'an array' : typeof rawSpec}. It carries the trigger's parameters, never a payload.`);
|
|
182
|
+
} else {
|
|
183
|
+
spec = rawSpec;
|
|
184
|
+
// The size bound is over the SERIALIZED spec, exactly as the DB CHECK measures
|
|
185
|
+
// it — and JSON.stringify throws on a cycle, which is why this is guarded.
|
|
186
|
+
let serialized = null;
|
|
187
|
+
try {
|
|
188
|
+
serialized = JSON.stringify(spec);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
E('trigger_spec_unserializable', 'trigger_spec',
|
|
191
|
+
`trigger_spec: could not be serialized (${(err && err.message) || 'circular structure'}) — it must be plain JSON data.`);
|
|
192
|
+
spec = null;
|
|
193
|
+
}
|
|
194
|
+
if (serialized !== null && serialized.length > TRIGGER_SPEC_MAX) {
|
|
195
|
+
E('trigger_spec_too_long', 'trigger_spec',
|
|
196
|
+
`trigger_spec: ${serialized.length} serialized characters exceeds the ${TRIGGER_SPEC_MAX} limit — it is parameters, never payload.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- the event catalog, fail-closed ---------------------------------------
|
|
201
|
+
// Checked whenever an event NAME is present, and additionally REQUIRED for an
|
|
202
|
+
// event trigger. An inert `event:` on an on-demand definition is either a
|
|
203
|
+
// typo'd trigger_type or dead config; refusing an unwired name in both places
|
|
204
|
+
// is what "fails closed on any unwired event name" actually means.
|
|
205
|
+
if (spec) {
|
|
206
|
+
const event = safeGet(spec, 'event');
|
|
207
|
+
const hasEventKey = event !== undefined && event !== null;
|
|
208
|
+
if (triggerType === 'event' && !isNonEmptyString(event)) {
|
|
209
|
+
E('trigger_event_missing', 'trigger_spec.event',
|
|
210
|
+
`trigger_spec.event: required for an 'event' trigger — a definition with no event name can never fire. Wired events: ${EVENT_CATALOG.join(', ')}.`);
|
|
211
|
+
} else if (hasEventKey && isNonEmptyString(event) && !EVENT_CATALOG.includes(event)) {
|
|
212
|
+
E('trigger_event_unwired', 'trigger_spec.event',
|
|
213
|
+
`trigger_spec.event: ${JSON.stringify(event)} is not a wired event and is refused rather than stored — nothing dispatches it, so the agent would look armed and never fire. Wired events: ${EVENT_CATALOG.join(', ')}.`);
|
|
214
|
+
} else if (hasEventKey && !isNonEmptyString(event) && triggerType !== 'event') {
|
|
215
|
+
E('trigger_event_unwired', 'trigger_spec.event',
|
|
216
|
+
`trigger_spec.event: ${JSON.stringify(event)} is not a wired event name. Wired events: ${EVENT_CATALOG.join(', ')}.`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- model tier -----------------------------------------------------------
|
|
221
|
+
const rawTier = safeGet(obj, 'model_tier');
|
|
222
|
+
const modelTier = rawTier === undefined || rawTier === null ? 'default' : rawTier;
|
|
223
|
+
if (!MODEL_TIERS.includes(modelTier)) {
|
|
224
|
+
E('model_tier_unknown', 'model_tier',
|
|
225
|
+
`model_tier: ${JSON.stringify(rawTier)} is not a known tier (${MODEL_TIERS.join(', ')}). The tier is stored and resolved to a concrete model at fire time; there is deliberately no 'main' tier.`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- scope: shape ---------------------------------------------------------
|
|
229
|
+
const rawScopeModules = safeGet(obj, 'scope_modules');
|
|
230
|
+
let scopeModules = null;
|
|
231
|
+
if (rawScopeModules === undefined || rawScopeModules === null) {
|
|
232
|
+
scopeModules = [];
|
|
233
|
+
} else if (!Array.isArray(rawScopeModules) || !rawScopeModules.every(isNonEmptyString)) {
|
|
234
|
+
E('scope_modules_invalid', 'scope_modules',
|
|
235
|
+
'scope_modules: must be an array of non-empty module-key strings.');
|
|
236
|
+
} else {
|
|
237
|
+
scopeModules = rawScopeModules;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const rawScopePaths = safeGet(obj, 'scope_paths');
|
|
241
|
+
let scopePaths = null;
|
|
242
|
+
if (rawScopePaths === undefined || rawScopePaths === null) {
|
|
243
|
+
scopePaths = [];
|
|
244
|
+
} else if (!Array.isArray(rawScopePaths) || !rawScopePaths.every(isNonEmptyString)) {
|
|
245
|
+
E('scope_paths_invalid', 'scope_paths',
|
|
246
|
+
'scope_paths: must be an array of non-empty repo-relative path strings.');
|
|
247
|
+
} else {
|
|
248
|
+
const escaping = rawScopePaths.filter((p) => !isSafeScopePath(p));
|
|
249
|
+
if (escaping.length > 0) {
|
|
250
|
+
E('scope_paths_invalid', 'scope_paths',
|
|
251
|
+
`scope_paths: ${escaping.map((p) => JSON.stringify(p)).join(', ')} escape the repository tree — a scope path must be relative and contain no '..' segment.`);
|
|
252
|
+
} else {
|
|
253
|
+
scopePaths = rawScopePaths;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- scope: the wall ------------------------------------------------------
|
|
258
|
+
// Note the empty-allowlist behaviour: with nothing allowed, every declared
|
|
259
|
+
// module is out of bounds. That is the fail-closed default, not an oversight.
|
|
260
|
+
if (scopeModules && scopeModules.length > 0) {
|
|
261
|
+
const outOfBounds = scopeModules.filter((m) => !allowedModules.includes(m));
|
|
262
|
+
if (outOfBounds.length > 0) {
|
|
263
|
+
E('scope_out_of_bounds', 'scope_modules',
|
|
264
|
+
`scope_modules: ${outOfBounds.map((m) => JSON.stringify(m)).join(', ')} ${outOfBounds.length === 1 ? 'is' : 'are'} outside the allowed module set (${allowedModules.length ? allowedModules.join(', ') : 'none — no allowed modules were supplied, so nothing is in scope'}).`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The rank wall, over whatever scope survived the bounds check. An unresolved
|
|
268
|
+
// rank is treated as insufficient, never as a reason to skip the check.
|
|
269
|
+
const protectedHits = scopeModules.filter((m) => protectedModules.includes(m));
|
|
270
|
+
if (protectedHits.length > 0) {
|
|
271
|
+
const rankIdx = typeof authorRank === 'string' ? RANK_ORDER.indexOf(authorRank) : -1;
|
|
272
|
+
if (rankIdx < RANK_ORDER.indexOf(SCOPE_RANK_FLOOR)) {
|
|
273
|
+
E('scope_protected_rank', 'scope_modules',
|
|
274
|
+
`scope_modules: ${protectedHits.map((m) => JSON.stringify(m)).join(', ')} ${protectedHits.length === 1 ? 'is a protected module' : 'are protected modules'} and require a ${SCOPE_RANK_FLOOR}+ author; the author's resolved rank is ${JSON.stringify(authorRank ?? null)} (ADR 0016 / ADR 0043). Authority comes from the author's live rank, never from the definition.`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (errors.length > 0) return { ok: false, errors, value: null };
|
|
280
|
+
|
|
281
|
+
// The normalized row, built fresh — the caller's object is never mutated, and
|
|
282
|
+
// arrays are copied so a later mutation of the input cannot reach the registry.
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
errors: [],
|
|
286
|
+
value: {
|
|
287
|
+
name,
|
|
288
|
+
title: isNonEmptyString(safeGet(obj, 'title')) ? safeGet(obj, 'title') : null,
|
|
289
|
+
persona,
|
|
290
|
+
trigger_type: triggerType,
|
|
291
|
+
trigger_spec: { ...spec },
|
|
292
|
+
model_tier: modelTier,
|
|
293
|
+
scope_modules: [...scopeModules],
|
|
294
|
+
scope_paths: [...scopePaths],
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = {
|
|
300
|
+
validateAgentDefinition,
|
|
301
|
+
EVENT_CATALOG,
|
|
302
|
+
TRIGGER_TYPES,
|
|
303
|
+
MODEL_TIERS,
|
|
304
|
+
RANK_ORDER,
|
|
305
|
+
SCOPE_RANK_FLOOR,
|
|
306
|
+
};
|
|
@@ -191,7 +191,33 @@ function boardVotesNeed(ctx) {
|
|
|
191
191
|
};
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
|
|
194
|
+
// task 1003178 (C08, goal 1000075): an open mingle INTRODUCTION waiting on you.
|
|
195
|
+
// Third sibling of the two collab needs above, here for the same reason and with
|
|
196
|
+
// the same posture — an introduction nobody surfaces is merely stored, and the
|
|
197
|
+
// needs roster is how a collab feature reaches the whole hall instead of sitting
|
|
198
|
+
// on a page of its own (which is the thing C08's criterion refuses).
|
|
199
|
+
//
|
|
200
|
+
// 'info' and NEVER a demand, and the wording carries that: mingle is declinable
|
|
201
|
+
// without penalty (C07, ADR 0187 §5), so copy that pressured the reader would BE
|
|
202
|
+
// the penalty the decision forbids. The same argument the recommendation need
|
|
203
|
+
// makes, applied to a social invitation, where it matters more.
|
|
204
|
+
function mingleIntroductionsNeed(ctx) {
|
|
205
|
+
const n = Number(ctx.openMingleIntroductions) || 0;
|
|
206
|
+
if (n <= 0) return null;
|
|
207
|
+
const subject = n === 1 ? 'An introduction is' : `${n} introductions are`;
|
|
208
|
+
return {
|
|
209
|
+
id: 'mingle_introductions',
|
|
210
|
+
title: n === 1 ? 'Someone from another craft was introduced to you' : 'You have introductions waiting',
|
|
211
|
+
what: 'Mingle pairs builders across crafts — you opted in, and a pairing is waiting on your answer.',
|
|
212
|
+
state: 'action_needed',
|
|
213
|
+
severity: 'info',
|
|
214
|
+
message: `${subject} waiting on you. Saying no costs nothing at all — no rank, no karma, no streak, `
|
|
215
|
+
+ 'and it does not change how often you are paired again.',
|
|
216
|
+
action: { label: n === 1 ? 'See the introduction' : 'See the introductions', href: '/#/collab' },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed];
|
|
195
221
|
|
|
196
222
|
// ---- founding grace (task 1002739 / direction §5.6, idea 1000701) -----------
|
|
197
223
|
//
|
|
@@ -269,7 +295,7 @@ module.exports = {
|
|
|
269
295
|
computeNeeds,
|
|
270
296
|
// exported for tests / direct reuse
|
|
271
297
|
artKeyNeed,
|
|
272
|
-
helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed,
|
|
298
|
+
helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed,
|
|
273
299
|
foundingGraceActive,
|
|
274
300
|
FOUNDING_GRACE_DAYS,
|
|
275
301
|
SETTINGS_PATH,
|
|
@@ -46,6 +46,25 @@
|
|
|
46
46
|
</div>
|
|
47
47
|
</section>
|
|
48
48
|
|
|
49
|
+
<!-- SOCIAL MODE ONLY (task 1003178 / C08). Not a page and not a nav item:
|
|
50
|
+
this panel is part of Collab, and it appears because the hall is in
|
|
51
|
+
social mode — the shell stamps data-social="on" on the document root
|
|
52
|
+
from /me, and collab.js reveals this section from that. With social
|
|
53
|
+
off the page is exactly what it was, which is the point: the hall
|
|
54
|
+
changes, it does not gain a destination. -->
|
|
55
|
+
<section class="scroll" id="cb-mingle-section" aria-labelledby="cb-mingle-h" hidden>
|
|
56
|
+
<h2 class="scroll__h" id="cb-mingle-h">Introductions</h2>
|
|
57
|
+
<p class="scroll__lede" id="cb-mingle-note">
|
|
58
|
+
Mingle pairs you with someone from a different craft. Saying no costs nothing
|
|
59
|
+
at all — no rank, no karma, no streak, and it does not change how often you
|
|
60
|
+
are paired again.
|
|
61
|
+
</p>
|
|
62
|
+
<ul class="ov-rows" id="cb-mingle"></ul>
|
|
63
|
+
<div class="empty-state" id="cb-mingle-empty" hidden>
|
|
64
|
+
<p>No introductions right now. You will see one here when a pairing round runs.</p>
|
|
65
|
+
</div>
|
|
66
|
+
</section>
|
|
67
|
+
|
|
49
68
|
<section class="scroll" aria-labelledby="cb-mine-h">
|
|
50
69
|
<h2 class="scroll__h" id="cb-mine-h">Your asks</h2>
|
|
51
70
|
<p class="scroll__lede">
|
|
@@ -143,6 +143,78 @@
|
|
|
143
143
|
? `Matching your craft: ${crafts.join(', ')}.`
|
|
144
144
|
: 'You have not set a craft, so only requests addressed to you by name reach you.';
|
|
145
145
|
}
|
|
146
|
+
|
|
147
|
+
// The social half. Best-effort and LAST: an introductions read that fails
|
|
148
|
+
// must never take the help requests down with it — those are the page.
|
|
149
|
+
try { await loadMingle(); } catch (_) { /* the panel stays as it was */ }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- social mode: introductions (task 1003178 / C08) ----------------------
|
|
153
|
+
//
|
|
154
|
+
// READ THE MODE OFF THE DOCUMENT, never re-derive it. The shell stamps
|
|
155
|
+
// data-social="on" from /me's resolved pair of switches (C07: the project
|
|
156
|
+
// enabled mingle AND this builder opted in). Asking the server again here would
|
|
157
|
+
// be a second answer to one question, and a page that decided for itself
|
|
158
|
+
// whether social was on could disagree with the rest of the hall.
|
|
159
|
+
const socialOn = () => document.documentElement.dataset.social === 'on';
|
|
160
|
+
|
|
161
|
+
// The other person in the pair — the row is written from the VIEWER's side, so
|
|
162
|
+
// it names who they were introduced to rather than echoing both ids back.
|
|
163
|
+
function otherOf(pair, meId) {
|
|
164
|
+
const lo = String(pair.builder_lo), hi = String(pair.builder_hi);
|
|
165
|
+
return String(meId) === lo
|
|
166
|
+
? { id: hi, login: pair.hi_login, name: pair.hi_name, craft: pair.craft_hi, myCraft: pair.craft_lo }
|
|
167
|
+
: { id: lo, login: pair.lo_login, name: pair.lo_name, craft: pair.craft_lo, myCraft: pair.craft_hi };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function mingleRow(pair, meId) {
|
|
171
|
+
const o = otherOf(pair, meId);
|
|
172
|
+
const who = escapeHtml(o.name || o.login || `builder ${o.id}`);
|
|
173
|
+
return `<li class="ov-row">
|
|
174
|
+
<div class="ov-row__main">
|
|
175
|
+
<a class="ov-row__title" href="/builders/profile?id=${escapeHtml(String(o.id))}">${who}</a>
|
|
176
|
+
<p class="ov-row__sub">A ${escapeHtml(String(o.craft))} — you are a ${escapeHtml(String(o.myCraft))}. Mingle pairs across crafts.</p>
|
|
177
|
+
</div>
|
|
178
|
+
<div class="ov-row__right">
|
|
179
|
+
<div class="ov-row__actions">
|
|
180
|
+
<button class="btn-accent" data-mingle="accepted" data-id="${escapeHtml(String(pair.id))}">Say yes</button>
|
|
181
|
+
<button class="btn-ghost" data-mingle="declined" data-id="${escapeHtml(String(pair.id))}">Not now</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</li>`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function loadMingle() {
|
|
188
|
+
const section = $('#cb-mingle-section');
|
|
189
|
+
if (!section) return;
|
|
190
|
+
// Off is off: the section stays hidden and nothing is fetched. A page that
|
|
191
|
+
// fetched anyway would make "social is off" cost a request on every load.
|
|
192
|
+
if (!socialOn()) { section.hidden = true; return; }
|
|
193
|
+
section.hidden = false;
|
|
194
|
+
|
|
195
|
+
const r = await api.request('GET', '/mingle');
|
|
196
|
+
const pairs = (r.ok && r.data && r.data.open_pairs) || [];
|
|
197
|
+
const meId = (r.ok && r.data && r.data.you) || null;
|
|
198
|
+
paint('#cb-mingle', '#cb-mingle-empty', pairs.map((p) => mingleRow(p, meId)).join(''), pairs.length);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function decideMingle(id, decision) {
|
|
202
|
+
if (settling) return;
|
|
203
|
+
settling = true;
|
|
204
|
+
try {
|
|
205
|
+
const r = await api.request('POST', `/mingle/pairs/${id}/decide`, { decision });
|
|
206
|
+
if (!r.ok) {
|
|
207
|
+
toast(window.OTB.errMessage ? window.OTB.errMessage(r.data) : 'Could not answer that introduction.');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// Neutral either way. "Declined" must not read as a failure — declining is
|
|
211
|
+
// free, and a toast that sounded disappointed would be the penalty C07's
|
|
212
|
+
// decision forbids, delivered by the UI instead of the schema.
|
|
213
|
+
toast(decision === 'accepted' ? 'Said yes — say hello.' : 'Not now. Nothing changes for you.');
|
|
214
|
+
await load();
|
|
215
|
+
} finally {
|
|
216
|
+
settling = false;
|
|
217
|
+
}
|
|
146
218
|
}
|
|
147
219
|
|
|
148
220
|
async function settle(id, status) {
|
|
@@ -162,6 +234,12 @@
|
|
|
162
234
|
}
|
|
163
235
|
|
|
164
236
|
document.addEventListener('click', (ev) => {
|
|
237
|
+
const mingleBtn = ev.target && ev.target.closest ? ev.target.closest('[data-mingle]') : null;
|
|
238
|
+
if (mingleBtn) {
|
|
239
|
+
ev.preventDefault();
|
|
240
|
+
decideMingle(Number(mingleBtn.getAttribute('data-id')), mingleBtn.getAttribute('data-mingle'));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
165
243
|
const btn = ev.target && ev.target.closest ? ev.target.closest('[data-settle]') : null;
|
|
166
244
|
if (!btn) return;
|
|
167
245
|
ev.preventDefault();
|
|
@@ -205,6 +205,12 @@
|
|
|
205
205
|
rank: normalizeRank(builder?.rank),
|
|
206
206
|
graduated,
|
|
207
207
|
showWork: shipStepCurrent,
|
|
208
|
+
// task 1003178 (C08): the index owns its own /me, so it passes the social
|
|
209
|
+
// mode through too — otherwise the hall's busiest page would be the one
|
|
210
|
+
// page the mode never reached. Read off ctx.lastMeState (the live getter),
|
|
211
|
+
// not a local: renderNav takes (builder, onboarding) and is called from
|
|
212
|
+
// five sites, so there is no `me` in scope here.
|
|
213
|
+
social: (ctx.lastMeState && ctx.lastMeState.social) || null,
|
|
208
214
|
builder,
|
|
209
215
|
});
|
|
210
216
|
|
|
@@ -522,7 +522,32 @@
|
|
|
522
522
|
}
|
|
523
523
|
}
|
|
524
524
|
|
|
525
|
-
//
|
|
525
|
+
// SOCIAL MODE (task 1003178 / C08, goal 1000075). The owner's directive is that
|
|
526
|
+
// social is "scoped into the ENTIRE builders hall, not a single social tab", and
|
|
527
|
+
// the criterion says outright that a /social route with the same hall around it
|
|
528
|
+
// does NOT satisfy it. So social is a MODE stamped on the document root, beside
|
|
529
|
+
// data-theme and for the same reason: every hall page loads this shell, so one
|
|
530
|
+
// attribute lets any surface — a stylesheet, a page script, a widget — read the
|
|
531
|
+
// mode without a route, a nav item, or a second fetch.
|
|
532
|
+
//
|
|
533
|
+
// OFF is the default and the fallback. `/me.social` is null when lifecycle is
|
|
534
|
+
// disabled or the read failed, and null is OFF: a mode that cannot be read is
|
|
535
|
+
// not on. It is only ON when BOTH of C07's switches are (the project enabled
|
|
536
|
+
// mingle AND this builder opted in) — the server resolves that pair, this never
|
|
537
|
+
// re-derives it.
|
|
538
|
+
//
|
|
539
|
+
// Written as an attribute rather than kept in `access` on purpose: CSS can hit
|
|
540
|
+
// it (`:root[data-social="on"] …`), which is what lets a surface change how it
|
|
541
|
+
// READS without every page script learning about social.
|
|
542
|
+
function applySocial(social) {
|
|
543
|
+
var on = !!(social && social.enabled === true);
|
|
544
|
+
var root = document.documentElement;
|
|
545
|
+
if (on) root.dataset.social = 'on';
|
|
546
|
+
else delete root.dataset.social;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// applyAccess({ authed, rank, graduated, showWork, social }) — settle nav
|
|
550
|
+
// visibility and the social mode.
|
|
526
551
|
// Called by builders.js on the index (it owns /me there) and by the shell's own
|
|
527
552
|
// probe elsewhere. Cosmetic only; the server enforces (ADR 0016).
|
|
528
553
|
function applyAccess(state) {
|
|
@@ -531,6 +556,7 @@
|
|
|
531
556
|
if ('rank' in state) access.rank = state.rank ? String(state.rank).toLowerCase() : null;
|
|
532
557
|
if ('graduated' in state) access.graduated = !!state.graduated;
|
|
533
558
|
if ('showWork' in state) access.showWork = !!state.showWork;
|
|
559
|
+
if ('social' in state) applySocial(state.social);
|
|
534
560
|
if (state.builder) renderUser(state.builder);
|
|
535
561
|
}
|
|
536
562
|
document.querySelectorAll('#app-sidebar [data-gate]').forEach(function (el) {
|
|
@@ -744,6 +770,9 @@
|
|
|
744
770
|
rank: me.builder.rank,
|
|
745
771
|
graduated: !!(me.onboarding && me.onboarding.graduated),
|
|
746
772
|
showWork: showWork,
|
|
773
|
+
// task 1003178 (C08): the mode rides the identity every page already
|
|
774
|
+
// fetches, so no surface needs a route or a second call to know.
|
|
775
|
+
social: me.social || null,
|
|
747
776
|
builder: me.builder,
|
|
748
777
|
});
|
|
749
778
|
}).catch(function () { /* cosmetic only */ });
|
|
@@ -22,6 +22,7 @@ const goalSuggest = require('./goal-suggest');
|
|
|
22
22
|
const helpRequests = require('./help-requests');
|
|
23
23
|
const taskRecommendations = require('./task-recommendations');
|
|
24
24
|
const activity = require('./activity');
|
|
25
|
+
const mingle = require('./mingle');
|
|
25
26
|
|
|
26
27
|
module.exports = {
|
|
27
28
|
// --- retired doorway leaks (ADR 0093 §3) ---------------------------------
|
|
@@ -52,6 +53,13 @@ module.exports = {
|
|
|
52
53
|
// builder, exposed the same way and for the same reason — a recommendation
|
|
53
54
|
// nobody surfaces is merely stored.
|
|
54
55
|
openTaskRecommendationsFor: (...a) => taskRecommendations.countOpenForRecipient(...a),
|
|
56
|
+
// task 1003178 (C08): social MODE, and the count of open introductions. The
|
|
57
|
+
// mode is the pair of switches C07 resolved (project enabled AND this builder
|
|
58
|
+
// opted in) — the kernel's /me carries it so every hall surface can read the
|
|
59
|
+
// mode WITHOUT importing this module, which is what makes social a mode rather
|
|
60
|
+
// than a page. The count feeds the needs roster, the same slot C01 and C03 use.
|
|
61
|
+
socialModeFor: (...a) => mingle.mingleStateFor(...a),
|
|
62
|
+
openMingleIntroductionsFor: (...a) => mingle.countOpenPairsFor(...a),
|
|
55
63
|
getTask: (...a) => db.getTask(...a),
|
|
56
64
|
// findTaskBySourceRef: the create-vector idempotency read (task 1003114 / R03
|
|
57
65
|
// of goal 1000074). copy-desk turns an artist's copy proposal into a task and
|
|
@@ -306,6 +306,22 @@ async function openPairsFor(builderId, deps = {}) {
|
|
|
306
306
|
return rows;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
// How many open introductions are waiting on this builder — the count the hall's
|
|
310
|
+
// needs roster renders (task 1003178 / C08). A COUNT rather than the rows,
|
|
311
|
+
// because /me is the session-open hot path and the roster only needs a number;
|
|
312
|
+
// the rows are `openPairsFor`, read by the surface that shows them.
|
|
313
|
+
async function countOpenPairsFor(builderId, deps = {}) {
|
|
314
|
+
const activePool = deps.pool || pool;
|
|
315
|
+
const { rows } = await activePool.query(
|
|
316
|
+
`SELECT count(*)::int AS n
|
|
317
|
+
FROM lifecycle_mingle_pairs
|
|
318
|
+
WHERE status = 'proposed'
|
|
319
|
+
AND (builder_lo = $1 OR builder_hi = $1)`,
|
|
320
|
+
[builderId]
|
|
321
|
+
);
|
|
322
|
+
return rows[0] ? rows[0].n : 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
309
325
|
// Accept or decline an introduction you are part of.
|
|
310
326
|
//
|
|
311
327
|
// ONE ROW, ONE TABLE. There is nothing else to write, and that absence IS the
|
|
@@ -333,5 +349,5 @@ module.exports = {
|
|
|
333
349
|
parseEnabled, projectEnabled, setProjectEnabled,
|
|
334
350
|
optInState, setOptIn, mingleStateFor,
|
|
335
351
|
eligibleBuilders, isCrossCraft, planPairs, pairKey, openPairKeys,
|
|
336
|
-
runMingleRound, openPairsFor, decidePair,
|
|
352
|
+
runMingleRound, openPairsFor, countOpenPairsFor, decidePair,
|
|
337
353
|
};
|
|
@@ -55,6 +55,11 @@ module.exports = function buildMingleRouter() {
|
|
|
55
55
|
return res.json({
|
|
56
56
|
mingle: state,
|
|
57
57
|
open_pairs: pairs,
|
|
58
|
+
// task 1003178 (C08): WHICH SIDE OF THE PAIR THE CALLER IS. A pair row
|
|
59
|
+
// carries builder_lo/builder_hi with no marker for "you", so a surface
|
|
60
|
+
// rendering it from the viewer's side would otherwise need a second /me
|
|
61
|
+
// fetch just to find out which id to name. The route already knows.
|
|
62
|
+
you: String(req.builder.id),
|
|
58
63
|
// Served VERBATIM from the module. The criterion asks for cross-craft to be
|
|
59
64
|
// chosen deliberately and RECORDED; this is where it is read, and a
|
|
60
65
|
// restatement here could drift from the matcher that actually runs.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.519",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.519",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.519",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|