@bongos/core 1.19.763 → 1.19.765

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.
@@ -0,0 +1,162 @@
1
+ // modules/provisioning/capacity.js — how many projects a shared box can hold, and
2
+ // whether one more fits (ADR 0285, task 1003927).
3
+ //
4
+ // ADR 0285 measured the live control plane and priced a project at its WARM size:
5
+ // ~80 MB (a 70 MB process plus one busy Postgres backend), on top of a fixed base
6
+ // the box pays whatever the project count (~750 MB with the control plane on it,
7
+ // ~630 MB on an overflow box). The ceiling is `floor((usable MiB − fixed) / 80)`,
8
+ // which reproduces the ADR's table from the box's own MemTotal — 15 on today's
9
+ // s-1vcpu-2gb, 39 on an s-2vcpu-4gb control plane, 41 on a 4 GB overflow box — so
10
+ // a resize (task 1003923) raises the ceiling by itself, with no knob to remember.
11
+ //
12
+ // Two walls, asked in order, both honest refusals rather than a silent overcommit:
13
+ // 1. the CEILING, counted against the rows the runner has placed or is placing
14
+ // on that box (`provisioning` + `active`). Not `error`: those rows hold no
15
+ // running unit (verified on the live fleet, 2026-09-16), and a re-provision
16
+ // runs this same check for itself.
17
+ // 2. a live MemAvailable FLOOR, because the ledger cannot see a stray unit or a
18
+ // project that grew — the rows say how many, the kernel says how much is left.
19
+ //
20
+ // PURE except the two I/O helpers at the bottom (`countPlaced` runs one query on
21
+ // the db it is handed; `readLocalMeminfo` reads this host's /proc/meminfo, for the
22
+ // web tier and the CLI, which run ON the control plane). The runner reads the same
23
+ // file through its box executor instead, so an overflow box is measured where the
24
+ // project will actually run (scripts/gds/provision-capacity.js). No module-api, no
25
+ // express, no pool of its own — this file is the shared arithmetic, not a surface.
26
+ 'use strict';
27
+
28
+ const fs = require('node:fs');
29
+
30
+ // ADR 0285 §1–§2. PROJECT_MB is the warm unit; FIXED_MB is the box's own overhead
31
+ // by role; DEFAULT_CEILING is the ADR's smallest-tier number, used ONLY when the box
32
+ // cannot be measured (dry-run, a fake executor, an unreadable /proc) — refusing
33
+ // conservatively beats guessing generously. RESIZE_AT is the ADR's "resize at 12 of
34
+ // 15" trigger. The floor is two warm projects plus margin, per the task's brief.
35
+ const PROJECT_MB = 80;
36
+ const FIXED_MB = Object.freeze({ 'control-plane': 750, overflow: 630 });
37
+ const DEFAULT_CEILING = 15;
38
+ const DEFAULT_MEM_FLOOR_MB = 256;
39
+ const RESIZE_AT = 0.8;
40
+ // The states that hold (or are about to hold) a slot on the box.
41
+ const PLACED_STATES = Object.freeze(['provisioning', 'active']);
42
+
43
+ function numEnv(name, dflt) {
44
+ const v = process.env[name];
45
+ if (v == null || v === '') return dflt;
46
+ const n = Number(v);
47
+ return Number.isFinite(n) && n > 0 ? n : dflt;
48
+ }
49
+ // The two knobs, read at call time so a test can set them per case. Both live in
50
+ // the runner's box.env; the web tier's copy (web.env) must match if the override
51
+ // is used — deriving from MemTotal is what makes the two agree with no knob at all.
52
+ function envOverrides() {
53
+ return { override: numEnv('PROVISION_BOX_CEILING', null), floorMb: numEnv('PROVISION_MEM_FLOOR_MB', DEFAULT_MEM_FLOOR_MB) };
54
+ }
55
+
56
+ // parseMeminfo — MemTotal / MemAvailable out of /proc/meminfo text, in MiB.
57
+ // Null for a field that is absent, so "unmeasured" stays distinguishable from 0.
58
+ function parseMeminfo(text) {
59
+ const grab = (key) => {
60
+ const m = new RegExp(`^${key}:\\s+(\\d+)\\s*kB`, 'm').exec(String(text || ''));
61
+ return m ? Math.floor(Number(m[1]) / 1024) : null;
62
+ };
63
+ return { memTotalMb: grab('MemTotal'), memAvailableMb: grab('MemAvailable') };
64
+ }
65
+
66
+ // placementBox — WHICH box a shape lands on, and therefore which rows share its
67
+ // ceiling and which fixed base applies. A standalone always runs control-plane-
68
+ // local (task 2074); a co-tenant runs on the remote co-hosting box only when the
69
+ // runner is in remote-exec mode (ADR 0130), else here. A dedicated droplet is its
70
+ // own box and has no shared ceiling → null (no preflight).
71
+ function placementBox(hostingShape, { remoteHost = null } = {}) {
72
+ if (hostingShape === 'dedicated') return null;
73
+ if (hostingShape === 'co-tenant' && remoteHost) {
74
+ return { box: 'overflow', shapes: ['co-tenant'], fixedMb: FIXED_MB.overflow };
75
+ }
76
+ return { box: 'control-plane', shapes: remoteHost ? ['standalone'] : ['standalone', 'co-tenant'], fixedMb: FIXED_MB['control-plane'] };
77
+ }
78
+
79
+ // ceilingFor — the slot count for a box: an explicit override wins, else the ADR
80
+ // 0285 formula over the measured MemTotal, else the conservative default. `source`
81
+ // says which, so every surface can show where its number came from.
82
+ function ceilingFor({ override = null, memTotalMb = null, fixedMb = FIXED_MB['control-plane'] } = {}) {
83
+ if (Number.isFinite(override) && override > 0) return { ceiling: Math.floor(override), source: 'env' };
84
+ if (Number.isFinite(memTotalMb) && memTotalMb > fixedMb) {
85
+ return { ceiling: Math.floor((memTotalMb - fixedMb) / PROJECT_MB), source: 'measured' };
86
+ }
87
+ return { ceiling: DEFAULT_CEILING, source: 'default' };
88
+ }
89
+
90
+ // placementVerdict — may ONE more project be placed? Ceiling first (the ledger's
91
+ // answer), then the live floor (the kernel's). The reason is written for the
92
+ // owner's card, not the journal: what is full, that nothing was placed, and the
93
+ // two ways out. An unmeasured MemAvailable cannot fail the floor — the ceiling is
94
+ // the wall that always applies, and a missing reading is logged by the caller.
95
+ function placementVerdict({ placed, ceiling, memAvailableMb = null, floorMb = DEFAULT_MEM_FLOOR_MB }) {
96
+ if (placed >= ceiling) {
97
+ return { ok: false, wall: 'ceiling', reason: `The shared box is full: ${placed} of ${ceiling} project slots are taken (ADR 0285). Nothing was placed. Free a slot by tearing down a project, or resize the box (task 1003923), then request again.` };
98
+ }
99
+ if (Number.isFinite(memAvailableMb) && memAvailableMb < floorMb) {
100
+ return { ok: false, wall: 'memory', reason: `The shared box has only ${memAvailableMb} MB of memory free, under the ${floorMb} MB a new project needs to start safely (${placed} of ${ceiling} slots taken). Nothing was placed. Free memory or a slot, or resize the box (task 1003923), then request again.` };
101
+ }
102
+ return { ok: true, wall: null, reason: null };
103
+ }
104
+
105
+ // capacitySummary — the one shape every reader gets: the fleet API, the runner
106
+ // log, the CLI status. `resize_due` is the ADR's 80% trigger, so the resize is
107
+ // scheduled at 12 of 15 rather than discovered at 15.
108
+ function capacitySummary({ box, placed, ceiling, source, memAvailableMb = null, floorMb = DEFAULT_MEM_FLOOR_MB }) {
109
+ const verdict = placementVerdict({ placed, ceiling, memAvailableMb, floorMb });
110
+ return {
111
+ box, placed, ceiling, source,
112
+ remaining: Math.max(0, ceiling - placed),
113
+ resize_at: Math.ceil(ceiling * RESIZE_AT),
114
+ resize_due: placed >= Math.ceil(ceiling * RESIZE_AT),
115
+ mem_available_mb: memAvailableMb,
116
+ mem_floor_mb: floorMb,
117
+ verdict,
118
+ };
119
+ }
120
+
121
+ // countPlaced — rows holding a slot on the box: the given shapes in PLACED_STATES.
122
+ // `excludeId` keeps a row from counting itself when a reconcile re-runs its own
123
+ // provision (it is already 'provisioning' then). Fail-soft on a fake db that
124
+ // answers with no rows.
125
+ async function countPlaced(db, shapes, { excludeId = null } = {}) {
126
+ const params = [shapes, PLACED_STATES];
127
+ let where = 'hosting_shape = ANY($1) AND status = ANY($2)';
128
+ if (excludeId != null) { params.push(excludeId); where += ` AND id <> $${params.length}`; }
129
+ const { rows } = await db.query(`SELECT count(*)::int AS n FROM provisioning_instances WHERE ${where}`, params);
130
+ return Number((rows && rows[0] && rows[0].n) || 0);
131
+ }
132
+
133
+ // readLocalMeminfo — THIS host's /proc/meminfo, for the surfaces that run on the
134
+ // control plane itself. Null (not a throw) where it does not exist, e.g. a dev box
135
+ // that is not Linux — the summary then reads 'default' and says so.
136
+ function readLocalMeminfo() {
137
+ try { return fs.readFileSync('/proc/meminfo', 'utf8'); } catch { return null; }
138
+ }
139
+
140
+ // fleetCapacity — the control-plane box's summary for the fleet API and the CLI:
141
+ // its rows against its ceiling, measured from this host. `meminfo` is injectable
142
+ // for tests; `remoteHost` mirrors the runner's PROVISION_REMOTE_HOST so the two
143
+ // count the same shapes (set it in web.env too if co-tenants ever run remotely).
144
+ async function fleetCapacity(db, { meminfo = readLocalMeminfo(), remoteHost = process.env.PROVISION_REMOTE_HOST || null } = {}) {
145
+ const target = placementBox('standalone', { remoteHost });
146
+ const { override, floorMb } = envOverrides();
147
+ const mem = parseMeminfo(meminfo);
148
+ const { ceiling, source } = ceilingFor({ override, memTotalMb: mem.memTotalMb, fixedMb: target.fixedMb });
149
+ const placed = await countPlaced(db, target.shapes);
150
+ return capacitySummary({ box: target.box, placed, ceiling, source, memAvailableMb: mem.memAvailableMb, floorMb });
151
+ }
152
+
153
+ // The constants a caller needs to reason about a verdict, plus the helpers the
154
+ // runner preflight and the fleet API compose. PROJECT_MB / FIXED_MB / RESIZE_AT
155
+ // and the two env readers stay private: nothing outside asks them separately from
156
+ // the summary they produce, and an export nobody consumes is one more thing to
157
+ // keep true (the paid-shape-gate precedent).
158
+ module.exports = {
159
+ DEFAULT_CEILING, DEFAULT_MEM_FLOOR_MB, PLACED_STATES,
160
+ parseMeminfo, placementBox, ceilingFor, placementVerdict, capacitySummary,
161
+ countPlaced, fleetCapacity,
162
+ };
@@ -25,7 +25,7 @@
25
25
  // POST /provisioning/instances/:id/teardown — request teardown of one's own
26
26
  // GET /provisioning/instances/:id/invite-suggestions — who is worth inviting (R28, ADR 0251)
27
27
  // Archon-scoped (requireRank('archon') — the fleet, the /boxes precedent):
28
- // GET /provisioning/fleet — the full instance roster
28
+ // GET /provisioning/fleet — the full instance roster + the box's slot `capacity` (ADR 0285)
29
29
  // GET /provisioning/fleet/cost-ledger — per-instance + total cost estimate
30
30
  // POST /provisioning/instances/:id/force-teardown — tear down ANY instance
31
31
  // public:
@@ -44,8 +44,7 @@
44
44
  const crypto = require('node:crypto');
45
45
  const express = require('express');
46
46
  const api = require('../../../src/module-api');
47
- const auth = api;
48
- const { pool, validateOrRespond, parseId } = api;
47
+ const auth = api, { pool, validateOrRespond, parseId } = api;
49
48
  const provisioning = require('../provisioning');
50
49
  const { onboardPlan, stepsForShape } = require('../onboard-plan');
51
50
  const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
@@ -54,6 +53,7 @@ const { makeAskRateLimiter, clientIp } = require('../rate-limit');
54
53
  const { requireAuthorityForMeteredShape } = require('../paid-shape-gate');
55
54
  const { adoptCredentialFault } = require('../credential-preflight');
56
55
  const catalogBridge = require('../catalog-bridge');
56
+ const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285, task 1003927)
57
57
  const { callbackPage } = require('./callback-page');
58
58
  // the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
59
59
  const { badProjectDetail, badModuleSelection, badProjectType } = require('./body-validators');
@@ -223,7 +223,7 @@ module.exports = function provisioningRoutes() {
223
223
  try {
224
224
  const status = req.query.status || undefined;
225
225
  const rows = await provisioning.listAllInstances(pool, { status });
226
- res.json({ instances: rows.map(fleetInstance) });
226
+ res.json({ instances: rows.map(fleetInstance), capacity: await capacity.fleetCapacity(pool).catch(() => null) });
227
227
  } catch (err) {
228
228
  log.error('[provisioning] GET /provisioning/fleet', err);
229
229
  res.fail('fleet_failed', { status: 500, message: 'internal error' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.763",
3
+ "version": "1.19.765",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.763",
9
+ "version": "1.19.765",
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.763",
3
+ "version": "1.19.765",
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",
@@ -0,0 +1,74 @@
1
+ // scripts/gds/provision-capacity.js — the runner's BOX-CAPACITY preflight
2
+ // (ADR 0285, task 1003927): before a standalone / co-tenant project is placed on a
3
+ // shared box, ask whether it fits, and refuse with an owner-readable reason when it
4
+ // does not. Nothing enforced the ceiling before this — the runner stood up project
5
+ // N+1 on a full box and swap hid the overcommit until a reboot or a fleet upgrade
6
+ // woke every project at once.
7
+ //
8
+ // The arithmetic and the two walls (row ceiling, live MemAvailable floor) live in
9
+ // modules/provisioning/capacity.js, shared with the fleet API so both surfaces show
10
+ // one number. This file is the runner-side composition: it reads /proc/meminfo
11
+ // through the BOX executor — the control plane for a standalone (task 2074), the
12
+ // remote co-hosting box for a co-tenant under remote-exec (ADR 0130) — so an
13
+ // overflow box is measured where the project will actually run. A sibling of
14
+ // provision-config / -repo / -units / -net; provision.js requires it back.
15
+ 'use strict';
16
+
17
+ const { CONFIG } = require('./provision-config.js');
18
+ const capacity = require('../../modules/provisioning/capacity.js');
19
+
20
+ // The kernel's own two lines, kept small so the journal stays readable. allowFail:
21
+ // a box without procfs (never, on Linux) reads as "unmeasured", and the ceiling
22
+ // then falls to ADR 0285's conservative default rather than aborting the standup.
23
+ const MEMINFO_CMD = "grep -E '^Mem(Total|Available):' /proc/meminfo";
24
+
25
+ // capacityPreflight — { ok:true, …summary } when the project fits (or the shape has
26
+ // no shared box), { ok:false, error, …summary } when it does not. The refusal is a
27
+ // RETURN, never a throw: the drain loop retries it (a teardown may free a slot), and
28
+ // on retirement writes `error` onto the instance's card without flipping its status
29
+ // — so a project that was never placed is never labelled broken. Dry-run measures
30
+ // and reports but refuses nothing, since it places nothing either way.
31
+ async function capacityPreflight(inst, deps) {
32
+ const { db, provisioning, exec, log, apply } = deps;
33
+ const target = capacity.placementBox(inst.hosting_shape, { remoteHost: CONFIG.remoteHost });
34
+ if (!target) return { ok: true, skipped: 'dedicated' };
35
+ const boxExec = target.box === 'control-plane' ? (deps.controlExec || exec) : exec;
36
+ let res = null;
37
+ try { res = boxExec(MEMINFO_CMD, { allowFail: true }); } catch { res = null; }
38
+ const mem = capacity.parseMeminfo(res && res.stdout);
39
+ const { ceiling, source } = capacity.ceilingFor({ override: CONFIG.boxCeiling, memTotalMb: mem.memTotalMb, fixedMb: target.fixedMb });
40
+ const placed = await capacity.countPlaced(db, target.shapes, { excludeId: inst.id });
41
+ const floorMb = CONFIG.memFloorMb || capacity.DEFAULT_MEM_FLOOR_MB;
42
+ const summary = capacity.capacitySummary({ box: target.box, placed, ceiling, source, memAvailableMb: mem.memAvailableMb, floorMb });
43
+ const free = mem.memAvailableMb != null ? `${mem.memAvailableMb} MB free` : 'memory unmeasured';
44
+ const where = `${target.box} box at ${placed} of ${ceiling} slots (${source}, ${free})`;
45
+ if (!apply) {
46
+ log(` [preflight] ${where} — would refuse if full (dry-run measures nothing and places nothing)`);
47
+ return { ok: true, dryRun: true, ...summary };
48
+ }
49
+ if (source === 'default') log(` [preflight] ⚠ could not measure the box — using ADR 0285's smallest-tier ceiling (${ceiling}); set PROVISION_BOX_CEILING to override`);
50
+ if (!summary.verdict.ok) {
51
+ log(` ✖ [preflight] ${where} — refusing to place ${inst.slug} (${summary.verdict.wall} wall)`);
52
+ if (provisioning && provisioning.recordEvent) {
53
+ await provisioning.recordEvent(db, {
54
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'placement-refused',
55
+ detail: summary.verdict.reason, actor: 'runner',
56
+ }).catch(() => {});
57
+ }
58
+ return { ok: false, error: summary.verdict.reason, ...summary };
59
+ }
60
+ log(` [preflight] ${where} ✓${summary.resize_due ? ' — at or past 80%: schedule the resize (task 1003923) or open the next box' : ''}`);
61
+ return { ok: true, ...summary };
62
+ }
63
+
64
+ // statusCapacityLine — the "box at N of M" line `provision.js status` prints, read
65
+ // from this host (the CLI runs on the control plane), so the resize is scheduled
66
+ // at 80% rather than discovered at the ceiling.
67
+ async function statusCapacityLine(db) {
68
+ const c = await capacity.fleetCapacity(db);
69
+ const free = c.mem_available_mb != null ? `, ${c.mem_available_mb} MB free` : '';
70
+ const due = c.resize_due ? ' — at or past 80%: resize or open the next box (ADR 0285, task 1003923)' : '';
71
+ return ` ${c.box} box: ${c.placed} of ${c.ceiling} project slots taken (${c.source}${free})${due}`;
72
+ }
73
+
74
+ module.exports = { MEMINFO_CMD, capacityPreflight, statusCapacityLine };
@@ -123,6 +123,17 @@ const CONFIG = {
123
123
  // fails fast (never hangs on a prompt); accept-new pins the host key on first
124
124
  // connect (the operator can pre-seed known_hosts for strict pinning).
125
125
  sshOpts: process.env.PROVISION_SSH_OPTS || '-o BatchMode=yes -o StrictHostKeyChecking=accept-new',
126
+ // ── Box capacity (ADR 0285, task 1003927) ─────────────────────────────────
127
+ // How many projects the box the runner places on may hold. UNSET (default)
128
+ // derives it from that box's own MemTotal by the ADR's formula (15 on today's
129
+ // s-1vcpu-2gb, 39 after the resize), so a resize raises it with no knob to
130
+ // remember; set it only to pin a number the measurement should not decide (a box
131
+ // that also hosts something the ledger cannot see). The floor is the live
132
+ // MemAvailable a placement must still find — two warm projects plus margin
133
+ // (modules/provisioning/capacity.js holds the default). Both live in box.env;
134
+ // mirror an override into web.env so the fleet API reports the same number.
135
+ boxCeiling: numEnv('PROVISION_BOX_CEILING', null),
136
+ memFloorMb: numEnv('PROVISION_MEM_FLOOR_MB', null),
126
137
  };
127
138
 
128
139
  // task 1002757 — the provisioner's git identity resolves from the instance's own
@@ -53,6 +53,7 @@ const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES,
53
53
  const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
54
54
  const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
55
55
  const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
56
+ const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
56
57
 
57
58
  function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
58
59
  // `shown` (task 1003140): what to LOG in place of the command text — for a command
@@ -258,7 +259,8 @@ async function provisionInstance(inst, deps) {
258
259
  log(` [preflight] would verify the Cloudflare token can reach zone ${zoneForDomain(inst.domain)} before the address step`);
259
260
  }
260
261
  }
261
-
262
+ const room = await capacityPreflight(inst, deps); // box capacity (ADR 0285, task 1003927): an honest refusal BEFORE the status flip — never a silent oversubscribe
263
+ if (room.ok === false) return room;
262
264
  if (apply) await provisioning.setInstanceStatus(db, inst.id, 'provisioning');
263
265
  const patch = {};
264
266
 
@@ -1358,7 +1360,7 @@ async function cmdStatus(deps) {
1358
1360
  monthly += Number(r.cost_estimate_usd || 0);
1359
1361
  log(` ${String(r.slug).padEnd(20)} ${String(r.status).padEnd(13)} ${r.hosting_shape.padEnd(10)} port=${r.port || '-'} @${r.github_login} $${Number(r.cost_estimate_usd || 0).toFixed(2)}/mo ${r.domain || ''}`);
1360
1362
  }
1361
- log(`\n estimated run-rate: ~$${monthly.toFixed(2)}/mo`);
1363
+ log(`\n estimated run-rate: ~$${monthly.toFixed(2)}/mo\n${await statusCapacityLine(db)}`); // + "box at N of M" (ADR 0285, task 1003927)
1362
1364
  return { count: rows.length };
1363
1365
  }
1364
1366
 
@@ -29,6 +29,7 @@ const ME_SCOPED_PATHS = [
29
29
  'me/profile-nudge',
30
30
  'me/render',
31
31
  'me/wandering',
32
+ 'me/interaction',
32
33
  'me/skill-prefs',
33
34
  'me/sound-prefs',
34
35
  'me/event-sound-prefs',
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.763'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.765'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -130,6 +130,42 @@ test('wandering: lowest priority — emitted after methodology/status advisories
130
130
  assert.equal(a[a.length - 1].id, 'wandering', 'wandering is emitted last');
131
131
  });
132
132
 
133
+ // ---------- evaluateTriggers: interaction profile (task 2010, delivered 1002514) ----------
134
+ //
135
+ // This branch shipped with task 2010 and had NO coverage here, which is part of
136
+ // why the profile could be believed to have no consumer at all. It relays only —
137
+ // interaction-prefs.js resolves — so these pin the relay, plus the one end-to-end
138
+ // fact clause 1 of `accessibility-fluency-profile` turns on.
139
+
140
+ test('interaction: a profile with inject:true emits the pitch contract', () => {
141
+ const a = conductor.evaluateTriggers({
142
+ interaction: { inject: true, contract: 'Interaction profile for this builder — pitch replies accordingly:' },
143
+ });
144
+ const i = a.find((x) => x.id === 'interaction');
145
+ assert.ok(i, 'expected an interaction advisory');
146
+ assert.match(i.text, /pitch replies accordingly/);
147
+ });
148
+
149
+ test('interaction: inject:false, an empty contract, or a null knob emit nothing', () => {
150
+ assert.ok(!ids(conductor.evaluateTriggers({ interaction: { inject: false, contract: 'x' } })).includes('interaction'));
151
+ assert.ok(!ids(conductor.evaluateTriggers({ interaction: { inject: true, contract: '' } })).includes('interaction'));
152
+ assert.ok(!ids(conductor.evaluateTriggers({ interaction: null })).includes('interaction'));
153
+ assert.ok(!ids(conductor.evaluateTriggers({})).includes('interaction'));
154
+ });
155
+
156
+ // The clause-1 proof, run against the REAL resolver rather than a hand-written
157
+ // payload: the accessible DEFAULT profile — the one a builder who never opens
158
+ // Settings has — must reach a session. Before task 1002514 every default field
159
+ // carried an empty fragment, so describeForApi({}) came back inject:false and
160
+ // this advisory was never emitted for the builder it was written for.
161
+ test('interaction: the DEFAULT profile reaches a session end to end', () => {
162
+ const interactionPrefs = require('../modules/builder-settings/interaction-prefs.js');
163
+ const a = conductor.evaluateTriggers({ interaction: interactionPrefs.describeForApi({}) });
164
+ const i = a.find((x) => x.id === 'interaction');
165
+ assert.ok(i, 'a builder on all defaults must still be described to the session');
166
+ assert.match(i.text, /non-technical/);
167
+ });
168
+
133
169
  // The nudge says "your claim's", singular, so it is measured against the claim
134
170
  // bound to THIS worktree (task 1003837). These three carry a worktree_name and a
135
171
  // matching `worktree` for that reason; the four below pin what changed.
@@ -160,6 +160,55 @@ test('the wandering pick is a radio row, wired on change', () => {
160
160
  assert.match(radio, /height:\s*24px/, 'a native radio is 13px; the floor is 24');
161
161
  });
162
162
 
163
+ // ---- the interaction panel (task 1002514) ----------------------------------
164
+ //
165
+ // Three facts a browser is silent about: the panel is rendered by a script that
166
+ // is not settings.js, so the wiring between the two halves is the thing that can
167
+ // silently break; and the carve exists because settings.js is against the
168
+ // 1500-line ratchet, which is a fact a reader will otherwise undo.
169
+
170
+ test('the interaction panel is wired end to end across its two files', () => {
171
+ const html = read('settings.html');
172
+ const panelJs = read('settings-interaction.js');
173
+
174
+ // The markup half lives in settings.html and must join a group that already
175
+ // exists — the rail renders groups, so a section in an unknown data-group is
176
+ // markup nothing ever shows.
177
+ const section = html.match(/<section class="scroll" id="interaction-scroll"[^>]*>/);
178
+ assert.ok(section, 'the panel has a section');
179
+ assert.match(section[0], /data-group="preferences"/, 'it joins a group the rail already renders');
180
+ assert.match(section[0], /\bhidden\b/, 'hidden until its own read succeeds, like every other panel');
181
+ assert.match(html, /<script src="\/builders\/settings-interaction\.js/, 'the page loads the panel script');
182
+
183
+ // The script half self-boots and reveals the section. If it stopped doing
184
+ // either, the panel would simply never appear and no test would notice.
185
+ assert.match(panelJs, /\$\('#interaction-scroll'\)\.hidden = false/, 'the script reveals its own section');
186
+ assert.match(panelJs, /setup\(\);/, 'it self-boots — settings.js does not call it');
187
+
188
+ // settings.js keeps ONLY the anchor entry, so /settings#interaction activates
189
+ // the group before the panel paints.
190
+ assert.match(settingsJs, /'interaction': 'interaction-scroll'/, 'the deep-link anchor stays in settings.js');
191
+ assert.doesNotMatch(settingsJs, /function renderInteraction\(/, 'the renderer moved out and must not come back inline');
192
+ });
193
+
194
+ test('the interaction panel stays out of settings.js, which is against the size ratchet', () => {
195
+ // Not style: inlining this panel put settings.js at 1601 lines and regressed
196
+ // oversized_file_count 0→1 and excess_lines_total 0→102 (fitness-ratchets.js,
197
+ // OVERSIZE_LINES = 1500). The settings-sessions.js precedent, same reason.
198
+ const lines = settingsJs.split('\n').length;
199
+ assert.ok(lines < 1500, `settings.js is ${lines} lines — at or over the 1500 ratchet; carve the next panel out too`);
200
+ });
201
+
202
+ test('a default value is named once, by the API, not twice by the row', () => {
203
+ // The first render read "Non-technical (default) · DEFAULT": interaction-prefs.js
204
+ // already ends every default value's LABEL with "(default)", and the row also
205
+ // carried the wandering picker's .prow__tag. One source for the word.
206
+ const panelJs = read('settings-interaction.js');
207
+ const render = panelJs.match(/function render\([\s\S]*?\n \}\n/);
208
+ assert.ok(render, 'render is a whole function');
209
+ assert.doesNotMatch(render[0], /prow__tag/, 'the row must not re-state "default" beside a label that already says it');
210
+ });
211
+
163
212
  test('.craft-pref is a 24px target with a 14px drawn box — it was 13x13, the smallest control in the hall', () => {
164
213
  const box = ruleOf(panelCss, '.craft-pref');
165
214
  assert.ok(box, '.craft-pref has a rule');
@@ -6,19 +6,47 @@ import { createRequire } from 'node:module';
6
6
  const require = createRequire(import.meta.url);
7
7
  const ip = require('../modules/builder-settings/interaction-prefs.js');
8
8
 
9
- test('defaults: unset prefs → all field defaults, empty contract, no inject', () => {
9
+ test('defaults: unset prefs → all field defaults', () => {
10
10
  const eff = ip.effectiveProfile({});
11
11
  assert.equal(eff.technical_level, 'non_technical');
12
12
  assert.equal(eff.toolchain_familiarity, 'new');
13
13
  assert.equal(eff.verbosity, 'concise');
14
- assert.equal(ip.contractFor({}), '', 'all-default profile injects nothing');
15
- assert.equal(ip.shouldInject({}), false);
16
14
  });
17
15
 
18
- test('null / missing behaviorPrefs is safe', () => {
16
+ // task 1002514, clause 1 of `accessibility-fluency-profile`. This REPLACES an
17
+ // assertion that the all-default profile injected nothing. That was correct while
18
+ // CLAUDE.md §2 carried the posture as prose, and it meant the accessible-by-
19
+ // default builder — the one who most needs the posture and least likely to open
20
+ // Settings — was the only builder the profile said nothing about.
21
+ test('the DEFAULT profile injects the accessible posture (it is a posture, not an absence)', () => {
22
+ const c = ip.contractFor({});
23
+ assert.notEqual(c, '', 'the accessible default must not be the silent one');
24
+ assert.equal(ip.shouldInject({}), true);
25
+ assert.match(c, /non-technical/, 'the vocabulary posture must arrive');
26
+ assert.match(c, /define a term the first time/);
27
+ assert.match(c, /cost, time and what the end user will see/, 'the framing posture must arrive');
28
+ assert.match(c, /step by step/, 'the toolchain posture must arrive');
29
+ assert.match(c, /concise/, 'the length posture must arrive');
30
+ });
31
+
32
+ test('null / missing behaviorPrefs is safe, and still resolves to the default posture', () => {
19
33
  assert.deepEqual(ip.storedProfile(null), {});
20
- assert.equal(ip.contractFor(null), '');
21
- assert.equal(ip.shouldInject(undefined), false);
34
+ assert.equal(ip.contractFor(null), ip.contractFor({}), 'absent prefs resolve exactly like empty prefs');
35
+ assert.equal(ip.shouldInject(undefined), true);
36
+ });
37
+
38
+ // The criterion's hard constraint: "an expert's terse experience is never
39
+ // degraded to help a newcomer." The expert contract must not merely differ from
40
+ // the newcomer one — it must be SHORTER, and it must tell the agent to skip.
41
+ test('an expert profile stays terse where a non-technical one is explained to', () => {
42
+ const beginner = ip.contractFor({});
43
+ const expert = ip.contractFor({
44
+ interaction: { technical_level: 'engineer', toolchain_familiarity: 'comfortable' },
45
+ });
46
+ assert.ok(expert.length < beginner.length, 'the expert contract must be shorter, not just different');
47
+ assert.match(expert, /skip basic explanations/);
48
+ assert.doesNotMatch(expert, /do not assume software-engineering vocabulary/);
49
+ assert.doesNotMatch(expert, /walk local tooling/);
22
50
  });
23
51
 
24
52
  test('a non-default field produces an injected contract', () => {
@@ -76,10 +104,21 @@ test('describeForApi exposes catalog + effective + contract + inject', () => {
76
104
  // The SURFACE half of the profile: how much of the work behind a reply gets
77
105
  // narrated. Default `show` keeps every existing builder on today's behavior.
78
106
 
79
- test('machinery defaults to show and injects nothing', () => {
107
+ // The assertion here used to be `contractFor({machinery:'show'}) === ''`, which
108
+ // measured the WHOLE contract's emptiness to prove one field contributed nothing.
109
+ // That conflated "this field is silent" with "every field is silent", so once the
110
+ // other defaults gained text (task 1002514) it would have gone red for a reason
111
+ // that had nothing to do with machinery. Same intent, pointed at the actual claim:
112
+ // adding `show` changes nothing, and `show` never names itself.
113
+ test('machinery defaults to show and contributes nothing to the contract', () => {
80
114
  assert.equal(ip.defaultValue('machinery'), 'show');
81
115
  assert.equal(ip.effectiveProfile({}).machinery, 'show');
82
- assert.equal(ip.contractFor({ interaction: { machinery: 'show' } }), '', 'the default must never spend session context');
116
+ assert.equal(
117
+ ip.contractFor({ interaction: { machinery: 'show' } }),
118
+ ip.contractFor({}),
119
+ 'an explicit `show` must read identically to leaving it unset',
120
+ );
121
+ assert.doesNotMatch(ip.contractFor({}), /Machinery/, 'the default must never spend session context');
83
122
  });
84
123
 
85
124
  test('machinery=quiet injects an outcomes-not-mechanism contract', () => {