@bongos/core 1.17.2 → 1.17.3

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.
Files changed (77) hide show
  1. package/.bongos-core.json +292 -32
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -2
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +65 -26
  8. package/docs/api-reference.md +3 -2
  9. package/modules/hall-ui/public/builders.js +96 -646
  10. package/modules/hall-ui/public/goal-inbox.js +67 -26
  11. package/modules/hall-ui/public/goals-page.js +4 -1
  12. package/modules/hall-ui/public/goals.html +8 -0
  13. package/modules/hall-ui/public/hall-kit.js +474 -0
  14. package/modules/hall-ui/public/hall-widgets.js +11 -15
  15. package/modules/hall-ui/public/instances-panel.js +24 -3
  16. package/modules/hall-ui/public/new-project-wizard.js +6 -5
  17. package/modules/hall-ui/public/style.css +125 -0
  18. package/modules/hall-ui/public/work.css +194 -0
  19. package/modules/hall-ui/public/work.html +46 -374
  20. package/modules/hall-ui/public/work.js +137 -102
  21. package/modules/lifecycle/criterion-suggest.js +80 -0
  22. package/modules/lifecycle/db.js +42 -5
  23. package/modules/lifecycle/routes/goals.js +26 -0
  24. package/modules/lifecycle/routes/tasks.js +21 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/scripts/hall-preview/README.md +103 -0
  28. package/scripts/hall-preview/fixtures/access-requests.json +1 -0
  29. package/scripts/hall-preview/fixtures/achievements.json +76 -0
  30. package/scripts/hall-preview/fixtures/analytics__builder.json +251 -0
  31. package/scripts/hall-preview/fixtures/audit-log.json +1 -0
  32. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +1 -0
  33. package/scripts/hall-preview/fixtures/builders__active-claims.json +1 -0
  34. package/scripts/hall-preview/fixtures/builders__directory.json +81 -0
  35. package/scripts/hall-preview/fixtures/builders__roster.json +1 -0
  36. package/scripts/hall-preview/fixtures/goals.json +502 -0
  37. package/scripts/hall-preview/fixtures/goals__25.json +96 -0
  38. package/scripts/hall-preview/fixtures/goals__26.json +67 -0
  39. package/scripts/hall-preview/fixtures/goals__3.json +103 -0
  40. package/scripts/hall-preview/fixtures/goals__35.json +49 -0
  41. package/scripts/hall-preview/fixtures/goals__conflicts.json +154 -0
  42. package/scripts/hall-preview/fixtures/goals__detail.json +96 -0
  43. package/scripts/hall-preview/fixtures/goals__inbox.json +5 -0
  44. package/scripts/hall-preview/fixtures/goals__requests.json +3 -0
  45. package/scripts/hall-preview/fixtures/grades__by-builder.json +1 -0
  46. package/scripts/hall-preview/fixtures/inbox.json +330 -0
  47. package/scripts/hall-preview/fixtures/instance.json +124 -0
  48. package/scripts/hall-preview/fixtures/leaderboard.json +184 -0
  49. package/scripts/hall-preview/fixtures/me.json +476 -0
  50. package/scripts/hall-preview/fixtures/me__recent-ships.json +304 -0
  51. package/scripts/hall-preview/fixtures/override-requests.json +1 -0
  52. package/scripts/hall-preview/fixtures/provisioning__fleet.json +1 -0
  53. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +1 -0
  54. package/scripts/hall-preview/fixtures/provisioning__instances.json +1 -0
  55. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +516 -0
  56. package/scripts/hall-preview/fixtures/public__bounty-table.json +37 -0
  57. package/scripts/hall-preview/fixtures/public__cost-summary.json +742 -0
  58. package/scripts/hall-preview/fixtures/public__estimation-drift.json +41 -0
  59. package/scripts/hall-preview/fixtures/public__grades.json +50 -0
  60. package/scripts/hall-preview/fixtures/public__progress.json +124 -0
  61. package/scripts/hall-preview/fixtures/public__recent-shipped.json +426 -0
  62. package/scripts/hall-preview/fixtures/public__repo-health.json +160 -0
  63. package/scripts/hall-preview/fixtures/public__repo-info.json +5 -0
  64. package/scripts/hall-preview/fixtures/security__docs.json +1 -0
  65. package/scripts/hall-preview/fixtures/security__reports.json +1 -0
  66. package/scripts/hall-preview/fixtures/sessions__search.json +1 -0
  67. package/scripts/hall-preview/fixtures/tasks.json +2420 -0
  68. package/scripts/hall-preview/fixtures/tasks__claimable.json +2555 -0
  69. package/scripts/hall-preview/fixtures/tasks__detail.json +111 -0
  70. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +330 -0
  71. package/scripts/hall-preview/fixtures/versions.json +89 -0
  72. package/scripts/hall-preview/refresh-fixtures.js +77 -0
  73. package/scripts/hall-preview/server.js +178 -0
  74. package/scripts/migrate.sh +29 -4
  75. package/src/bongos/optimizer.js +37 -2
  76. package/tests/criterion_suggest.mjs +106 -0
  77. package/tests/migrate_root_resolution.mjs +97 -0
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ // scripts/hall-preview/server.js — the hall preview harness (task 2167).
3
+ //
4
+ // The dev-box sandbox previews the GAME only (/builders 404s there, idea 490),
5
+ // so hall-ui changes are reviewed HERE: a tiny express app that serves the real
6
+ // modules/hall-ui/public files through the REAL serving seams (boot-time ?v=
7
+ // stamping, {{token}} replacement, __BRANDING__/__MODULES__ injection, theme
8
+ // markup) with the /api/gds|bongos reads answered from checked-in fixtures.
9
+ // Boots DB-less (pg pools connect lazily). Proven recipe from tasks 1723/1742,
10
+ // finally checked in.
11
+ //
12
+ // node scripts/hall-preview/server.js [--port 4600] [--brand <file>]
13
+ // [--modules-off dev-box,provisioning] [--empty]
14
+ //
15
+ // --port listen port (default 4600)
16
+ // --brand branding pack file — default config/branding.neutral.json
17
+ // (vanilla); pass config/branding.json for the instance pack
18
+ // --modules-off comma list of module keys to report disabled in
19
+ // window.__MODULES__ (verifies moduleOn() gating)
20
+ // --empty serve every fixture with its arrays emptied — the
21
+ // empty-state screenshot pass
22
+ //
23
+ // Browse http://builders.localhost:<port>/ — Chromium resolves *.localhost,
24
+ // and the `builders.` host prefix exercises the real subdomain serving path.
25
+ // Refresh fixtures from live data: node scripts/hall-preview/refresh-fixtures.js
26
+ 'use strict';
27
+
28
+ const path = require('path');
29
+ const fs = require('fs');
30
+
31
+ const ROOT = path.resolve(__dirname, '..', '..');
32
+ const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
33
+ const FIXTURES = path.join(__dirname, 'fixtures');
34
+
35
+ // ---- flags (before any src/ require: --brand must win the branding resolve) --
36
+ function arg(name) {
37
+ const i = process.argv.indexOf(name);
38
+ return i > -1 ? process.argv[i + 1] : null;
39
+ }
40
+ const PORT = Number(arg('--port')) || 4600;
41
+ const EMPTY = process.argv.includes('--empty');
42
+ const MODULES_OFF = String(arg('--modules-off') || '').split(',').map((s) => s.trim()).filter(Boolean);
43
+ // --modules-on: force-report modules as enabled — a dev box's local config
44
+ // often lacks prod's instance modules (provisioning, dev-box), and their
45
+ // widgets can't be reviewed without it (task 2169).
46
+ const MODULES_ON = String(arg('--modules-on') || '').split(',').map((s) => s.trim()).filter(Boolean);
47
+ const BRAND_FILE = arg('--brand') || 'config/branding.neutral.json';
48
+ process.env.GDS_BRANDING_FILE = path.resolve(ROOT, BRAND_FILE);
49
+
50
+ /* eslint-disable import/no-dynamic-require */
51
+ // express comes from this tree's node_modules when installed, else node's
52
+ // normal upward walk finds the main checkout's copy (a git worktree under
53
+ // .claude/worktrees/ has no node_modules of its own). The src/ seams below are
54
+ // ALWAYS taken from THIS tree so a worktree's edits are what gets previewed.
55
+ let express;
56
+ try { express = require(path.join(ROOT, 'node_modules', 'express')); }
57
+ catch (_) { express = require('express'); }
58
+ const { buildStampedHtml } = require(path.join(ROOT, 'src', 'asset-version.js'));
59
+ const { injectBrandingGlobal, mountInternalSurfaces } = require(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'));
60
+ const { clientModules } = require(path.join(ROOT, 'src', 'modules.js'));
61
+
62
+ // ---- fixtures ---------------------------------------------------------------
63
+ // File naming: the API path with slashes as "__" — /me/recent-ships →
64
+ // me__recent-ships.json. Query strings are ignored when matching. Id-bearing
65
+ // paths fall back to the generic names below so one fixture serves any id.
66
+ const FIXTURE_FALLBACKS = [
67
+ [/^goals\/\d+\/conflicts$/, 'goals__conflicts'],
68
+ [/^goals\/\d+\/requests$/, 'goals__requests'],
69
+ [/^goals\/\d+$/, 'goals__detail'],
70
+ [/^tasks\/\d+$/, 'tasks__detail'],
71
+ [/^analytics\/builder\/\d+$/, 'analytics__builder'],
72
+ [/^builders\/\d+\/active-claims$/, 'builders__active-claims'],
73
+ ];
74
+
75
+ function loadFixture(name) {
76
+ try {
77
+ return JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf8'));
78
+ } catch (_) { return undefined; }
79
+ }
80
+
81
+ // --empty: the same pages, with nothing in them — every array drains so each
82
+ // page's empty states render. Scalars/strings survive (identity stays real).
83
+ function emptied(v) {
84
+ if (Array.isArray(v)) return [];
85
+ if (v && typeof v === 'object') {
86
+ const out = {};
87
+ for (const [k, val] of Object.entries(v)) out[k] = emptied(val);
88
+ return out;
89
+ }
90
+ return v;
91
+ }
92
+
93
+ function fixtureFor(apiPath) {
94
+ const exact = loadFixture(apiPath.replace(/\//g, '__'));
95
+ if (exact !== undefined) return exact;
96
+ for (const [re, name] of FIXTURE_FALLBACKS) {
97
+ if (re.test(apiPath)) {
98
+ const hit = loadFixture(name);
99
+ if (hit !== undefined) return hit;
100
+ }
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ // ---- app ----------------------------------------------------------------------
106
+ const app = express();
107
+
108
+ // The API stub — intercepts every /api/gds/*, /api/bongos/*, /api/bongos/v1/*
109
+ // request BEFORE mountInternalSurfaces can reach the real routes (which would
110
+ // touch Postgres — the harness is DB-less by design). A plain function
111
+ // middleware, not a path mount: regex-mount semantics differ across express
112
+ // majors and a silent non-match here means a pg crash.
113
+ app.use((req, res, next) => {
114
+ const m = req.path.match(/^\/api\/(?:gds|bongos)(?:\/v1)?\/(.+)$/);
115
+ if (!m) return next();
116
+ const apiPath = m[1].replace(/\/$/, '');
117
+
118
+ // SSE live channel: emit one nudge (so "live refresh" paths run once), then
119
+ // keep-alive comments. Pages treat this exactly like prod's stream.
120
+ if (apiPath === 'live') {
121
+ res.set({ 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
122
+ res.flushHeaders();
123
+ setTimeout(() => res.write('event: nudge\ndata: {}\n\n'), 1200);
124
+ const tick = setInterval(() => res.write(': keep-alive\n\n'), 25000);
125
+ req.on('close', () => clearInterval(tick));
126
+ return;
127
+ }
128
+
129
+ if (req.method !== 'GET') {
130
+ return res.status(501).json({ error: { code: 'preview_readonly', message: 'the preview harness serves fixtures; writes are not wired' } });
131
+ }
132
+ const fix = fixtureFor(apiPath);
133
+ if (fix === undefined) {
134
+ console.log(` no fixture: ${apiPath} (add scripts/hall-preview/fixtures/${apiPath.replace(/\//g, '__')}.json)`);
135
+ return res.status(404).json({ error: { code: 'no_fixture', message: apiPath } });
136
+ }
137
+ res.json(EMPTY ? emptied(fix) : fix);
138
+ });
139
+
140
+ // The hall pages, pre-auth-gate, through the real seams: boot-time stamped
141
+ // (?v= content hashes), {{token}}-replaced, __BRANDING__/__MODULES__ injected,
142
+ // theme markup appended — byte-for-byte what prod serves after its gates.
143
+ const stamped = buildStampedHtml(PUB, '/builders/');
144
+ const mods = JSON.parse(JSON.stringify(clientModules()));
145
+ for (const key of MODULES_OFF) {
146
+ if (mods.enabled && key in mods.enabled) mods.enabled[key] = false;
147
+ else (mods.enabled = mods.enabled || {})[key] = false;
148
+ }
149
+ for (const key of MODULES_ON) {
150
+ (mods.enabled = mods.enabled || {})[key] = true;
151
+ }
152
+
153
+ function sendPage(res, name) {
154
+ const html = stamped[name];
155
+ if (!html) return false;
156
+ res.type('html').send(injectBrandingGlobal(html, undefined, mods));
157
+ return true;
158
+ }
159
+
160
+ app.get('/', (req, res, next) => { if (!sendPage(res, 'index.html')) next(); });
161
+ app.get(/^\/task\/\d+$/, (req, res, next) => { if (!sendPage(res, 'task.html')) next(); });
162
+ app.get(/^\/([a-z-]+)$/, (req, res, next) => {
163
+ if (!sendPage(res, `${req.params[0]}.html`)) next();
164
+ });
165
+
166
+ // Everything else — /builders/*.css|js assets, /lib/bongos-client.js, diagram
167
+ // PNGs — falls through to the real internal-surface mounting. Our handlers
168
+ // above registered first, so the hall's auth gates in there never fire for
169
+ // the page routes we already served.
170
+ mountInternalSurfaces(app);
171
+
172
+ // Loopback only — this is a pre-auth preview of hall pages with fixture data;
173
+ // *.localhost resolves to 127.0.0.1 so nothing else is needed.
174
+ app.listen(PORT, '127.0.0.1', () => {
175
+ console.log(`hall-preview: http://builders.localhost:${PORT}/`);
176
+ console.log(` brand: ${BRAND_FILE}${EMPTY ? ' (EMPTY fixtures)' : ''}${MODULES_OFF.length ? ` modules off: ${MODULES_OFF.join(',')}` : ''}`);
177
+ console.log(' pages:', Object.keys(stamped).map((f) => f.replace(/\.html$/, '')).sort().join(' '));
178
+ });
@@ -12,18 +12,43 @@ set -euo pipefail
12
12
  # alongside the instance, so both resolve to the SAME dir and this is byte-identical to the
13
13
  # old `cd "$(dirname "$0")/.."`. Once the core installs from node_modules, resolveCoreRoot()
14
14
  # points into the package (core migrations + src/) while resolveInstanceRoot() stays the host
15
- # repo (env → bongos.config → cwd). We resolve from the SCRIPT-relative dir so the result does
16
- # not depend on the caller's cwd (preserving the old script-relative behavior), and fail open to
17
- # that dir if node is unavailable so a deploy never breaks.
15
+ # repo (env → bongos.config → cwd).
16
+ #
17
+ # CORE root is resolved SCRIPT-relative (the package's own files sit next to this script), so it
18
+ # never depends on the caller's cwd. The INSTANCE root is the OPPOSITE: it MUST come from where
19
+ # migrate was INVOKED. `npm run migrate` from the instance sets INIT_CWD (and $PWD) to the
20
+ # instance dir, where bongos.config.json lives. Resolving the instance root script-relative would
21
+ # `cd` into the core PACKAGE first — whose dir has no bongos.config.json — so with no
22
+ # OTB_INSTANCE_ROOT env it falls back to the package dir as the "instance" and reads the core's
23
+ # VANILLA module config, SILENTLY skipping every host + flag-gated module migration (R89 rehearsal
24
+ # F6, task 2180: literal `npm run migrate` applied 115 rows and created no game schema; from the
25
+ # instance cwd it applied 121). So resolve the instance root from the invocation cwd, preferring an
26
+ # EXPLICIT pointer (OTB_INSTANCE_ROOT env, else the invocation cwd's bongos.config.json).
27
+ # resolveInstanceRootExplicit() returns '' (NOT process.cwd()) when neither is present, so we fall
28
+ # back to the script-relative DEFAULT_ROOT — the monolith case (cwd==core==instance), byte-identical
29
+ # to the old behavior, and a stray cwd can never masquerade as the instance. Fail open to
30
+ # DEFAULT_ROOT if node is unavailable so a deploy never breaks.
31
+ INVOKE_CWD="${INIT_CWD:-$PWD}"
18
32
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19
33
  DEFAULT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
20
34
  CORE_ROOT="$(cd "$DEFAULT_ROOT" && node -e "process.stdout.write(require('./src/instance-config').resolveCoreRoot())" 2>/dev/null || echo "$DEFAULT_ROOT")"
21
- INSTANCE_ROOT="$(cd "$DEFAULT_ROOT" && node -e "process.stdout.write(require('./src/instance-config').resolveInstanceRoot())" 2>/dev/null || echo "$DEFAULT_ROOT")"
35
+ INSTANCE_ROOT="$(cd "$INVOKE_CWD" && node -e "process.stdout.write(require('$DEFAULT_ROOT/src/instance-config').resolveInstanceRootExplicit() || '')" 2>/dev/null || true)"
36
+ INSTANCE_ROOT="${INSTANCE_ROOT:-$DEFAULT_ROOT}"
22
37
  # Run from the instance root so instance-relative paths resolve (the post-deploy art check below)
23
38
  # AND so the enabledModules() read finds the instance's config/modules.json (resolveInstanceRoot
24
39
  # falls back to cwd).
25
40
  cd "$INSTANCE_ROOT"
26
41
 
42
+ # Test/ops seam (task 2180): print the resolved roots and exit BEFORE any DB work, so a DB-free
43
+ # unit test (tests/migrate_root_resolution.mjs) can assert the INSTANCE root resolves from the
44
+ # invocation cwd (a consumer's bongos.config.json), not script-relative into the core package —
45
+ # the F6 regression. Also handy in the field: `npm run migrate -- --print-roots` shows what
46
+ # migrate sees.
47
+ if [ "${1:-}" = "--print-roots" ]; then
48
+ printf 'INVOKE_CWD=%s\nCORE_ROOT=%s\nINSTANCE_ROOT=%s\n' "$INVOKE_CWD" "$CORE_ROOT" "$INSTANCE_ROOT"
49
+ exit 0
50
+ fi
51
+
27
52
  DB="${PGDATABASE:-amazonprimea}"
28
53
 
29
54
  # --- Instance-seed policy (task 1704 / ADR 0105) --------------------------
@@ -53,10 +53,45 @@ async function optimizeSession({ builderId, mode, timeBudgetMin, maxParallel })
53
53
  // tasks_session_fit (derived fits_session_modes[] + credits_per_minute).
54
54
  // Also exclude tasks this builder already has an active claim on, mirroring
55
55
  // listClaimableTasks in db.js.
56
+ // task 2181 (incident, blocker 74): the view bodies (migration core_190) are
57
+ // inlined so this query takes only ACCESS SHARE on base tables — a detached
58
+ // backend queued an ACCESS EXCLUSIVE DDL on the views and parked every view
59
+ // SELECT behind it. Plan-identical (Postgres inlines simple views); change in
60
+ // lockstep with the views / listClaimableTasks if a migration alters them.
56
61
  const { rows } = await pool.query(
57
62
  `SELECT ct.*, sf.fits_session_modes, sf.credits_per_minute
58
- FROM claimable_tasks ct
59
- JOIN tasks_session_fit sf ON sf.id = ct.id
63
+ FROM (
64
+ SELECT t.*, pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
65
+ FROM tasks t
66
+ WHERE t.status = 'ready'
67
+ AND t.title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
68
+ AND NOT EXISTS (SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id)
69
+ AND NOT EXISTS (
70
+ SELECT 1 FROM claims c
71
+ JOIN tasks t2 ON t2.id = c.task_id
72
+ WHERE c.released_at IS NULL
73
+ AND t2.touches && t.touches
74
+ )
75
+ ) ct
76
+ JOIN (
77
+ SELECT t.id,
78
+ CASE
79
+ WHEN t.automation_tag = 'overnight-safe' AND COALESCE(t.manual_degree, 5) <= 2
80
+ AND NOT EXISTS (SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id)
81
+ THEN ARRAY['autonomous','check-in','active']
82
+ WHEN t.automation_tag = 'live-only' OR COALESCE(t.manual_degree, 5) >= 4
83
+ THEN ARRAY['active']
84
+ ELSE ARRAY['check-in','active']
85
+ END AS fits_session_modes,
86
+ CASE
87
+ WHEN t.credits_reward > 0
88
+ AND pms_calibrated_minutes(t.est_minutes, t.kind) IS NOT NULL
89
+ AND pms_calibrated_minutes(t.est_minutes, t.kind) > 0
90
+ THEN ROUND(t.credits_reward::numeric / pms_calibrated_minutes(t.est_minutes, t.kind), 3)
91
+ ELSE NULL
92
+ END AS credits_per_minute
93
+ FROM tasks t
94
+ ) sf ON sf.id = ct.id
60
95
  WHERE $1 = ANY(sf.fits_session_modes)
61
96
  AND NOT EXISTS (
62
97
  SELECT 1 FROM claims c
@@ -0,0 +1,106 @@
1
+ // tests/criterion_suggest.mjs
2
+ //
3
+ // Unit tests for suggestCriteria() — the pure criterion RECOMMENDER
4
+ // (modules/lifecycle/criterion-suggest.js, task 2165). Given a goal's criteria
5
+ // and a candidate task's title/description, it ranks the criteria by weighted
6
+ // lexical overlap so task-create can suggest which done-when criterion to link
7
+ // (the author confirms — nothing is auto-linked). No DB, so it runs in the
8
+ // DB-free suite.
9
+ //
10
+ // Run: node tests/criterion_suggest.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const { suggestCriteria, tokenize } = require('../modules/lifecycle/criterion-suggest.js');
17
+
18
+ let passed = 0;
19
+ let failed = 0;
20
+ function test(name, fn) {
21
+ try { fn(); passed++; console.log(` ok ${name}`); }
22
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
23
+ }
24
+
25
+ const crit = (id, criterion_id, criterion_md, satisfied = false) =>
26
+ ({ id, criterion_id, criterion_md, satisfied });
27
+
28
+ // A small, realistic goal-25-flavoured criterion set.
29
+ const CRITERIA = [
30
+ crit(138, 'bx-hall', 'Core task actions work in the builders hall without invoking the LLM.'),
31
+ crit(139, 'bx-identity', 'A builder can view and edit their own profile and read a leaderboard.'),
32
+ crit(140, 'bx-onramp', 'Newcomers never dead-end: auth and re-auth are browser-clickable.'),
33
+ crit(141, 'bx-box', 'A builder dev box and sandbox is robust: uncommitted work is not lost.'),
34
+ ];
35
+
36
+ test('ranks the most lexically-relevant criterion first', () => {
37
+ const out = suggestCriteria(CRITERIA, {
38
+ title: 'In-hall confirm button for criteria',
39
+ description: 'Let builders confirm a criterion in the hall without the LLM.',
40
+ });
41
+ assert.ok(out.length >= 1, 'expected at least one suggestion');
42
+ assert.equal(out[0].criterion_id, 'bx-hall', `expected bx-hall first, got ${out[0].criterion_id}`);
43
+ assert.ok(out[0].matched_terms.includes('hall'), 'should report the matched term');
44
+ });
45
+
46
+ test('title terms weigh more than description terms', () => {
47
+ // "profile" only in the title → should beat a body-only "hall" match.
48
+ const out = suggestCriteria(CRITERIA, {
49
+ title: 'Edit your profile',
50
+ description: 'Shown in the hall.',
51
+ });
52
+ assert.equal(out[0].criterion_id, 'bx-identity', `title term should win, got ${out[0].criterion_id}`);
53
+ const identity = out.find((c) => c.criterion_id === 'bx-identity');
54
+ const hall = out.find((c) => c.criterion_id === 'bx-hall');
55
+ assert.ok(hall === undefined || identity.score > hall.score, 'title match must outscore body match');
56
+ });
57
+
58
+ test('returns [] when nothing overlaps (no low-confidence noise)', () => {
59
+ const out = suggestCriteria(CRITERIA, {
60
+ title: 'Migrate the Postgres backup vendor to object storage',
61
+ description: 'Nightly encrypted dumps to a bucket.',
62
+ });
63
+ assert.deepEqual(out, [], 'no shared vocabulary → no suggestion');
64
+ });
65
+
66
+ test('never suggests an already-satisfied criterion', () => {
67
+ const satisfiedHall = [crit(138, 'bx-hall', 'Core task actions work in the builders hall.', true)];
68
+ const out = suggestCriteria(satisfiedHall, { title: 'hall task actions' });
69
+ assert.deepEqual(out, [], 'satisfied criteria are excluded');
70
+ });
71
+
72
+ test('respects the limit and is stable (id tiebreak on equal scores)', () => {
73
+ const tied = [
74
+ crit(20, 'b', 'builder profile'),
75
+ crit(10, 'a', 'builder profile'),
76
+ crit(30, 'c', 'builder profile'),
77
+ ];
78
+ const out = suggestCriteria(tied, { title: 'builder profile' }, { limit: 2 });
79
+ assert.equal(out.length, 2, 'limit caps the result');
80
+ assert.deepEqual(out.map((c) => c.id), [10, 20], 'equal scores break ties by ascending id');
81
+ });
82
+
83
+ test('includeZero returns all unsatisfied criteria in score order', () => {
84
+ const out = suggestCriteria(CRITERIA, { title: 'profile' }, { includeZero: true, limit: 10 });
85
+ assert.equal(out.length, 4, 'includeZero keeps the zero-overlap criteria');
86
+ assert.equal(out[0].criterion_id, 'bx-identity', 'best match still leads');
87
+ });
88
+
89
+ test('empty / malformed inputs are safe', () => {
90
+ assert.deepEqual(suggestCriteria([], { title: 'x' }), []);
91
+ assert.deepEqual(suggestCriteria(null, { title: 'x' }), []);
92
+ assert.deepEqual(suggestCriteria(CRITERIA, {}), [], 'no candidate terms → no suggestion');
93
+ });
94
+
95
+ test('tokenize drops stopwords, short tokens, and singularizes', () => {
96
+ const toks = tokenize('The builders can edit profiles and tasks.');
97
+ assert.ok(!toks.includes('the'), 'stopword dropped');
98
+ assert.ok(!toks.includes('can'), 'stopword dropped');
99
+ assert.ok(toks.includes('builder'), 'builders → builder');
100
+ assert.ok(toks.includes('profile'), 'profiles → profile');
101
+ assert.ok(toks.includes('task'), 'tasks → task');
102
+ });
103
+
104
+ console.log('');
105
+ console.log(`criterion-suggest recommender tests: ${passed} passed, ${failed} failed`);
106
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,97 @@
1
+ // tests/migrate_root_resolution.mjs
2
+ //
3
+ // R89 rehearsal F6 (task 2180): after the core→consumer split, scripts/migrate.sh
4
+ // must resolve the INSTANCE root from the INVOCATION cwd (OTB_INSTANCE_ROOT env,
5
+ // else the invocation cwd's bongos.config.json), NOT script-relative into the
6
+ // installed @bongos/core package. The old script-relative resolve `cd`'d into the
7
+ // package dir first — which has no bongos.config.json — so it silently picked the
8
+ // package as the "instance" and read the core's VANILLA module config, skipping
9
+ // every host + flag-gated module migration (rehearsal: `npm run migrate` applied
10
+ // 115 rows and created NO game schema; from the instance cwd it applied 121).
11
+ //
12
+ // This exercises migrate.sh's `--print-roots` seam (DB-free: it prints the resolved
13
+ // roots and exits before any DB work), so no Postgres is needed. CORE_ROOT must stay
14
+ // script-relative in every case; INSTANCE_ROOT must follow the invocation cwd.
15
+ //
16
+ // Run: node tests/migrate_root_resolution.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { execFileSync } from 'node:child_process';
20
+ import { mkdtempSync, mkdirSync, writeFileSync, realpathSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { fileURLToPath } from 'node:url';
23
+ import path from 'node:path';
24
+
25
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const MIGRATE = path.join(REPO_ROOT, 'scripts', 'migrate.sh');
27
+ const REPO_REAL = realpathSync(REPO_ROOT);
28
+
29
+ let passed = 0;
30
+ let failed = 0;
31
+ function t(name, fn) {
32
+ try { fn(); passed++; console.log(` PASS ${name}`); }
33
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
34
+ }
35
+
36
+ // Run `migrate.sh --print-roots` with a controlled cwd + INIT_CWD, and an env that
37
+ // carries NO *_INSTANCE_ROOT override, so we test cwd-based resolution alone.
38
+ function printRoots({ cwd, initCwd } = {}) {
39
+ const env = { ...process.env };
40
+ for (const k of Object.keys(env)) if (/_INSTANCE_ROOT$/.test(k)) delete env[k];
41
+ delete env.INIT_CWD;
42
+ if (initCwd) env.INIT_CWD = initCwd;
43
+ const out = execFileSync('bash', [MIGRATE, '--print-roots'], { cwd, env, encoding: 'utf8' });
44
+ const roots = {};
45
+ for (const line of out.split('\n')) {
46
+ const m = /^(INVOKE_CWD|CORE_ROOT|INSTANCE_ROOT)=(.*)$/.exec(line);
47
+ if (m) roots[m[1]] = m[2];
48
+ }
49
+ return roots;
50
+ }
51
+
52
+ // A synthetic consumer: a dir with a bongos.config.json pointing at itself, exactly
53
+ // as the post-split amazonprimea repo carries (bongos.config.json { instanceRoot: "." }).
54
+ function makeConsumer() {
55
+ const dir = realpathSync(mkdtempSync(path.join(tmpdir(), 'r89-consumer-')));
56
+ writeFileSync(path.join(dir, 'bongos.config.json'), JSON.stringify({ instanceRoot: '.' }));
57
+ return dir;
58
+ }
59
+ // A dir with NO bongos.config.json — stands in for the monolith / a stray cwd.
60
+ function makeBare() {
61
+ return realpathSync(mkdtempSync(path.join(tmpdir(), 'r89-bare-')));
62
+ }
63
+
64
+ // ---- the F6 regression: instance root follows the invocation cwd ----------
65
+ t('consumer run from its own dir: INSTANCE_ROOT resolves to the consumer (not the core)', () => {
66
+ const consumer = makeConsumer();
67
+ const r = printRoots({ cwd: consumer });
68
+ assert.equal(realpathSync(r.INSTANCE_ROOT), consumer, 'instance root must be the consumer dir');
69
+ assert.notEqual(realpathSync(r.INSTANCE_ROOT), REPO_REAL, 'must NOT fall back to the script-relative core root');
70
+ });
71
+
72
+ t('consumer via npm from elsewhere: INIT_CWD wins over the spawn cwd', () => {
73
+ const consumer = makeConsumer();
74
+ const elsewhere = makeBare();
75
+ // npm sets INIT_CWD to where `npm run` was invoked (the instance), even if a
76
+ // wrapper cd'd somewhere else first.
77
+ const r = printRoots({ cwd: elsewhere, initCwd: consumer });
78
+ assert.equal(realpathSync(r.INSTANCE_ROOT), consumer, 'INIT_CWD (the instance) must win');
79
+ });
80
+
81
+ // ---- the monolith / no-pointer fallback stays script-relative -------------
82
+ t('no bongos.config + no env: INSTANCE_ROOT falls back to the script-relative repo root', () => {
83
+ const bare = makeBare();
84
+ const r = printRoots({ cwd: bare });
85
+ assert.equal(realpathSync(r.INSTANCE_ROOT), REPO_REAL, 'a stray cwd must NOT masquerade as the instance');
86
+ });
87
+
88
+ // ---- CORE root is always script-relative, regardless of cwd ---------------
89
+ t('CORE_ROOT is always the script-relative repo root, independent of the invocation cwd', () => {
90
+ const consumer = makeConsumer();
91
+ const bare = makeBare();
92
+ assert.equal(realpathSync(printRoots({ cwd: consumer }).CORE_ROOT), REPO_REAL);
93
+ assert.equal(realpathSync(printRoots({ cwd: bare }).CORE_ROOT), REPO_REAL);
94
+ });
95
+
96
+ console.log(`\nmigrate_root_resolution: ${passed} passed, ${failed} failed`);
97
+ process.exit(failed ? 1 : 0);