@bongos/core 1.21.47 → 1.21.48

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.
@@ -162,23 +162,10 @@ WantedBy=timers.target
162
162
  // branding-file env line is written at all. Reading the neutral pack HERE is still correct: it is
163
163
  // the prefix floor an instance inherits unless its own pack overrides envPrefix, and every live
164
164
  // co-tenant pack pins `envPrefix: "CLOUDBONGOS"` explicitly.
165
- const ENV_PREFIX_PACK = 'config/branding.neutral.json';
166
-
167
- // The env-var prefix a provisioned instance actually RESOLVES — i.e. the envPrefix
168
- // of the pack it loads (ENV_PREFIX_PACK). src/instance-config.resolveEnv
169
- // reads <envPrefix>_<SUFFIX>, and src/branding.resolveBranding applies
170
- // <envPrefix>_{PUBLIC,BUILDERS,…}_ORIGIN overrides — so a web.env var only takes
171
- // effect under THIS prefix. Read from the pack (fail-soft to the vanilla default)
172
- // so it stays in lockstep instead of a magic string. (The prior slug-based prefix
173
- // was a latent bug: it never matched the neutral pack's CLOUDBONGOS envPrefix, so
174
- // the OAuth creds were silently unreadable — task 1972.)
175
- function provisionedEnvPrefix() {
176
- const { FALLBACK_ENV_PREFIX } = require('../../src/instance-config');
177
- try {
178
- const pack = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, ENV_PREFIX_PACK), 'utf8'));
179
- return pack.envPrefix || FALLBACK_ENV_PREFIX;
180
- } catch { return FALLBACK_ENV_PREFIX; }
181
- }
165
+ // ENV_PREFIX_PACK + provisionedEnvPrefix (and the task-1004573 pack checks) live in
166
+ // pack-env-prefix.js — provision-repo.js needs them too, and it cannot require this file
167
+ // (this file requires it). Re-exported below so every existing caller is unchanged.
168
+ const { ENV_PREFIX_PACK, provisionedEnvPrefix, envPrefixMismatch, normalizePackEnvPrefix } = require('./pack-env-prefix.js');
182
169
 
183
170
  // The per-instance secrets file body (the standup runbook's /etc/<inst>/web.env).
184
171
  // The OAuth client id/secret are GUIDED/manual inputs (no GitHub create-API) — the
@@ -430,6 +417,8 @@ function renamedEnvCarry(body) {
430
417
 
431
418
  module.exports = {
432
419
  ENV_PREFIX_PACK,
420
+ envPrefixMismatch,
421
+ normalizePackEnvPrefix,
433
422
  backupScriptPath,
434
423
  backupService,
435
424
  backupServicePath,
@@ -50,7 +50,7 @@ const crypto = require('node:crypto');
50
50
  const retry = require('./intent-retry.js'); // when a failed intent is tried again (task 1004447)
51
51
  const { CONFIG, MANIFEST_UA, softFailResult, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
52
52
  const { accountPreflight, appUser, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, 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');
53
- const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, renamedEnvCarry, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
53
+ const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, envPrefixMismatch, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, renamedEnvCarry, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
54
54
  const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, ingressPlaneGap, ingressPreflight, shouldFederate, zoneForDomain } = require('./provision-net.js');
55
55
  const { resolveProcessManager } = require('./provision-process.js');
56
56
  const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
@@ -1097,7 +1097,13 @@ async function placeManifestCreds(inst, mrow, deps) {
1097
1097
  if (!authConfigured) {
1098
1098
  // The App now EXISTS on the owner's account — keep its id + slug on the row so
1099
1099
  // the wizard can name it and say why "try again" makes a second one (task 1004572).
1100
- return { ok: false, codeSpent: true, appId: creds.appId, appSlug: creds.appSlug, error: 'the credentials are placed, but the project did not report sign-in ready in time' };
1100
+ // Say WHY when we can see it: an adopted repo's own pack may read other names (task 1004573).
1101
+ // A cloud-host checkout lives on the control plane (the scaffold reads it the same way), so
1102
+ // this is a plain file read — no shell. An unreadable or non-JSON pack names nothing.
1103
+ let packText = null;
1104
+ if (standalone) { try { packText = (deps.readFile || fs.readFileSync)(path.join(standaloneRoot(inst), 'config', 'branding.json'), 'utf8'); } catch { packText = null; } }
1105
+ const why = envPrefixMismatch(packText);
1106
+ return { ok: false, codeSpent: true, appId: creds.appId, appSlug: creds.appSlug, error: `the credentials are placed, but the project did not report sign-in ready in time${why ? ` — ${why}` : ''}` };
1101
1107
  }
1102
1108
  log(' [oauth-manifest] ✓ /healthz auth_configured=true — sign-in is live');
1103
1109
  } else {
@@ -190,7 +190,7 @@ function readConfigFileSync(name, { encoding = 'utf8', b = safeBrand() } = {}) {
190
190
  // per process naming the old spelling, the new one, and the release the old
191
191
  // stops working in; see warnLegacyEnv below. `warn` is injectable so a test can
192
192
  // assert the warning fires rather than only that the value resolves.
193
- function resolveEnv(suffix, { env = process.env, prefix = envPrefix(), legacy = LEGACY_ENV_PREFIXES, warn = warnLegacyEnv } = {}) {
193
+ function resolveEnv(suffix, { env = process.env, prefix = envPrefix(), legacy = LEGACY_ENV_PREFIXES, warn = warnLegacyEnv, warnPack = warnPackPrefix } = {}) {
194
194
  const canonical = `${prefix}_${suffix}`;
195
195
  const canonicalValue = prefix ? env[canonical] : undefined;
196
196
  const canonicalSet = canonicalValue !== undefined && canonicalValue !== '';
@@ -200,6 +200,22 @@ function resolveEnv(suffix, { env = process.env, prefix = envPrefix(), legacy =
200
200
  // on the winner would leave the "both set" operator with no notice at all.
201
201
  const seen = new Set([prefix].filter(Boolean));
202
202
  let winner = canonicalSet ? canonicalValue : undefined;
203
+ // THE PLATFORM'S SPELLING IS ALWAYS HEARD (task 1004573). The platform writes
204
+ // every hosted project's web.env as BONGOS_* whatever that project's own pack
205
+ // says. A project adopted from a repo whose config/branding.json still carried
206
+ // envPrefix CLOUDBONGOS read none of it — not its sign-in credentials, not its
207
+ // address, not one setting its owner chose — and nothing said so (mercury,
208
+ // 2026-10-04). So when the pack names another prefix, BONGOS_* is read right
209
+ // after it, ahead of the legacy list. The pack's own spelling still wins.
210
+ if (prefix && prefix !== FALLBACK_ENV_PREFIX) {
211
+ seen.add(FALLBACK_ENV_PREFIX);
212
+ const name = `${FALLBACK_ENV_PREFIX}_${suffix}`;
213
+ const v = env[name];
214
+ if (!canonicalSet && v !== undefined && v !== '') {
215
+ winner = v;
216
+ warnPack({ prefix, name });
217
+ }
218
+ }
203
219
  for (const pfx of legacy) {
204
220
  if (!pfx || seen.has(pfx)) continue;
205
221
  seen.add(pfx);
@@ -229,6 +245,17 @@ function warnLegacyEnv({ legacyName, canonicalName, shadowed, differs, log = con
229
245
  return true;
230
246
  }
231
247
 
248
+ // One line per process when a value had to come from the platform's BONGOS_*
249
+ // spelling because this project's pack names another prefix. Not a deprecation —
250
+ // BONGOS_ is the canonical name — so it says what to change instead: the pack.
251
+ let _packPrefixWarned = false;
252
+ function warnPackPrefix({ prefix, name, log = console } = {}) {
253
+ if (_packPrefixWarned) return false;
254
+ _packPrefixWarned = true;
255
+ log.warn(`[bongos] config/branding.json sets envPrefix "${prefix}", but ${name} (the platform's spelling) is what is set — reading it. Set envPrefix to "${FALLBACK_ENV_PREFIX}" to match.`);
256
+ return true;
257
+ }
258
+
232
259
  // NOT exported, deliberately: warnLegacyEnv, LEGACY_ENV_SUNSET_RELEASE and the
233
260
  // ledger are internals. resolveEnv's injectable `warn` is the whole test seam a
234
261
  // caller needs, and a test of the DEFAULT path captures console.warn — which
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.47'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.48'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -235,3 +235,56 @@ test('resolveApiBase: session:null does not read the session file from disk', ()
235
235
  // and callers that already know there is no session get a deterministic answer.
236
236
  assert.equal(ic.resolveApiBase({ session: null, env: {}, b: BRAND_B }), 'https://instance-b.example');
237
237
  });
238
+
239
+ // ── task 1004573: the platform's BONGOS_* spelling is always heard ────────────
240
+ // The platform writes every hosted project's web.env as BONGOS_*. Mercury's adopted
241
+ // config/branding.json said envPrefix CLOUDBONGOS, so it read none of it — not its
242
+ // sign-in credentials, not its address, not one setting (live, 2026-10-04).
243
+
244
+ test('resolveEnv: a pack naming another prefix still reads the platform\'s BONGOS_* (task 1004573)', () => {
245
+ const env = { BONGOS_GITHUB_CLIENT_ID: 'Iv23-platform' };
246
+ assert.equal(resolveEnv('GITHUB_CLIENT_ID', { prefix: 'CLOUDBONGOS', env, warn: () => {}, warnPack: () => {} }), 'Iv23-platform');
247
+ assert.equal(resolveEnv('GITHUB_CLIENT_ID', { prefix: 'OTB', env, warn: () => {}, warnPack: () => {} }), 'Iv23-platform');
248
+ });
249
+
250
+ test('resolveEnv: the pack\'s own spelling still WINS over the platform\'s (task 1004573)', () => {
251
+ const env = { CLOUDBONGOS_PUBLIC_ORIGIN: 'https://own.example', BONGOS_PUBLIC_ORIGIN: 'https://platform.example' };
252
+ assert.equal(resolveEnv('PUBLIC_ORIGIN', { prefix: 'CLOUDBONGOS', env, warn: () => {}, warnPack: () => {} }), 'https://own.example');
253
+ });
254
+
255
+ test('resolveEnv: BONGOS_* is read BEFORE the legacy list, and is never called deprecated (task 1004573)', () => {
256
+ const legacyWarnings = [];
257
+ const v = resolveEnv('JOINABILITY', { prefix: 'OTB', env: { BONGOS_JOINABILITY: 'open', PMS_JOINABILITY: 'closed' }, warn: (w) => legacyWarnings.push(w), warnPack: () => {} });
258
+ assert.equal(v, 'open', 'the canonical platform spelling beats a legacy one');
259
+ assert.ok(!legacyWarnings.some((w) => /^BONGOS_/.test(w.legacyName)), 'BONGOS_ is the canonical name — no deprecation warning names it');
260
+ });
261
+
262
+ test('resolveEnv: when it has to read BONGOS_*, it says to fix the pack — and only then (task 1004573)', () => {
263
+ const calls = [];
264
+ const warnPack = (w) => calls.push(w);
265
+ resolveEnv('LOOK', { prefix: 'CLOUDBONGOS', env: { BONGOS_LOOK: 'own' }, warn: () => {}, warnPack });
266
+ assert.deepEqual(calls, [{ prefix: 'CLOUDBONGOS', name: 'BONGOS_LOOK' }], 'names the pack prefix and the name it fell back to');
267
+ resolveEnv('LOOK', { prefix: 'CLOUDBONGOS', env: { CLOUDBONGOS_LOOK: 'x', BONGOS_LOOK: 'own' }, warn: () => {}, warnPack });
268
+ resolveEnv('LOOK', { prefix: 'BONGOS', env: { BONGOS_LOOK: 'own' }, warn: () => {}, warnPack });
269
+ assert.equal(calls.length, 1, 'silent when the pack\'s own spelling won, and when the pack is already BONGOS');
270
+ });
271
+
272
+ test('resolveEnv: the default pack warning names the fix, once per process (task 1004573)', () => {
273
+ const seen = [];
274
+ const orig = console.warn;
275
+ console.warn = (m) => seen.push(String(m));
276
+ try {
277
+ // the default warnPack — the real wiring, not a hand-called helper
278
+ resolveEnv('ONE', { prefix: 'CLOUDBONGOS', env: { BONGOS_ONE: '1' }, warn: () => {} });
279
+ resolveEnv('TWO', { prefix: 'CLOUDBONGOS', env: { BONGOS_TWO: '2' }, warn: () => {} });
280
+ } finally { console.warn = orig; }
281
+ const lines = seen.filter((m) => /envPrefix "CLOUDBONGOS"/.test(m));
282
+ assert.equal(lines.length, 1, 'one line, not one per setting');
283
+ assert.match(lines[0], /BONGOS_ONE/);
284
+ assert.match(lines[0], /Set envPrefix to "BONGOS"/);
285
+ });
286
+
287
+ test('resolveEnv: a pack already on BONGOS is unchanged (task 1004573)', () => {
288
+ assert.equal(resolveEnv('X', { prefix: 'BONGOS', env: { BONGOS_X: 'a', PMS_X: 'b' }, warn: () => {}, warnPack: () => {} }), 'a');
289
+ assert.equal(resolveEnv('X', { prefix: 'BONGOS', env: {}, warn: () => {}, warnPack: () => {} }), undefined);
290
+ });
@@ -312,6 +312,18 @@ test('the skill files unasked on a Bongos rant, and the kernel tells every sessi
312
312
  assert.match(kernel, /vents that Bongos itself is broken.*\/bongos-feedback/);
313
313
  });
314
314
 
315
+ // task 1004576: "Bongos feedback: …" (a calm wish, not a rant) fired no skill — the
316
+ // listing only described venting, and a since-deleted /feedback skill owned the phrase.
317
+ test('a labelled message or a wish about Bongos triggers the skill too, and nothing else claims the phrase', () => {
318
+ const skill = fs.readFileSync(path.join(ROOT, '.claude/skills/bongos-feedback/SKILL.md'), 'utf8');
319
+ const description = skill.split('\n---')[0];
320
+ assert.match(description, /Bongos feedback:/, 'the literal label is a trigger');
321
+ assert.match(description, /Bongos should/, 'a wish is a trigger');
322
+ assert.match(skill, /Suggestions count/);
323
+ assert.match(skill, /\*\*is\*\* the go/, 'a labelled message is sent without a second ask');
324
+ assert.ok(!fs.existsSync(path.join(ROOT, '.claude/skills/feedback')), 'no second skill competes for "feedback"');
325
+ });
326
+
315
327
  test('every report is secret-scrubbed before it leaves the machine, and never sent unscrubbed', async () => {
316
328
  const { scrubReport } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
317
329
  const token = 'ghp_' + 'a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8';
@@ -1330,6 +1330,59 @@ await ta('scaffoldStandaloneRepo(adopt): does NOT clobber an existing .gitignore
1330
1330
  assert.match(String(gi.body), /node_modules\//, 'and the entries `npm ci` + the deploy require are added');
1331
1331
  });
1332
1332
 
1333
+ // Task 1004573: adopt layers config only where ABSENT, so an owner's config/branding.json
1334
+ // kept whatever envPrefix it carried while web.env was written under the platform's —
1335
+ // mercury's said CLOUDBONGOS and read none of its credentials or settings.
1336
+ async function adoptWithPack(packText, mode = 'adopt') {
1337
+ const base = mkdtempSync(join(tmpdir(), `pack-${mode}-`));
1338
+ const origBase = P.CONFIG.standaloneBase;
1339
+ P.CONFIG.standaloneBase = base;
1340
+ try {
1341
+ mkdirSync(join(base, 'demo', 'config'), { recursive: true });
1342
+ writeFileSync(join(base, 'demo', 'config', 'branding.json'), packText);
1343
+ const { deps } = recorder({ apply: true });
1344
+ const seq = [];
1345
+ deps.exec = (cmd) => { seq.push(`exec ${cmd}`); return { ok: true, stdout: '' }; };
1346
+ deps.writeFile = (p, body) => { seq.push(`write ${p}`); seq.writes = (seq.writes || []).concat([{ p, body }]); return { ok: true }; };
1347
+ deps.getGithubToken = async () => ({ token: 'gho_TOK', scope: 'repo' });
1348
+ await P.scaffoldStandaloneRepo(
1349
+ { slug: 'demo', hosting_shape: 'cloud-host', target_ref: 'example-owner/demo', owner_builder_id: 7, onboard_mode: mode },
1350
+ { ...deps, instanceRoot: base },
1351
+ );
1352
+ return { seq, writes: seq.writes || [] };
1353
+ } finally {
1354
+ P.CONFIG.standaloneBase = origBase;
1355
+ rmSync(base, { recursive: true, force: true });
1356
+ }
1357
+ }
1358
+ const U2 = require('../scripts/gds/provision-units.js');
1359
+
1360
+ await ta('scaffoldStandaloneRepo(adopt): an owner pack on another envPrefix is corrected IN the adopt commit (task 1004573)', async () => {
1361
+ const owner = '{\n "identity": { "productName": "mercury" },\n "envPrefix": "CLOUDBONGOS",\n "configDir": "cloudbongos"\n}\n';
1362
+ const { seq, writes } = await adoptWithPack(owner);
1363
+ const w = writes.find((x) => /config\/branding\.json$/.test(x.p));
1364
+ assert.ok(w, 'the pack was rewritten');
1365
+ assert.equal(JSON.parse(w.body).envPrefix, U2.provisionedEnvPrefix(), 'to the prefix web.env is written under');
1366
+ assert.equal(w.body, owner.replace('"envPrefix": "CLOUDBONGOS"', `"envPrefix": "${U2.provisionedEnvPrefix()}"`), 'and NOTHING else in the owner\'s file moved');
1367
+ const wrote = seq.findIndex((s) => /^write .*config\/branding\.json$/.test(s));
1368
+ const added = seq.findIndex((s) => /git -C \S+ add -A/.test(s));
1369
+ assert.ok(wrote >= 0 && added > wrote, 'written before `git add -A`, so it rides the adopt commit to the owner\'s repo');
1370
+ });
1371
+
1372
+ await ta('scaffoldStandaloneRepo: a pack that already agrees, and a greenfield scaffold, are left alone (task 1004573)', async () => {
1373
+ const agree = await adoptWithPack(JSON.stringify({ envPrefix: U2.provisionedEnvPrefix() }));
1374
+ assert.ok(!agree.writes.some((x) => /config\/branding\.json$/.test(x.p)), 'agreeing pack untouched');
1375
+ const green = await adoptWithPack(JSON.stringify({ envPrefix: 'CLOUDBONGOS' }), 'greenfield');
1376
+ assert.ok(!green.writes.some((x) => /config\/branding\.json$/.test(x.p)), 'greenfield owns its config through init; no rewrite here');
1377
+ });
1378
+
1379
+ await ta('normalizePackEnvPrefix: rewrites only the value, refuses what it cannot rewrite safely (task 1004573)', async () => {
1380
+ assert.deepEqual(U2.normalizePackEnvPrefix('{"envPrefix":"OTB","x":1}', 'BONGOS'), { text: '{"envPrefix":"BONGOS","x":1}', from: 'OTB' });
1381
+ assert.equal(U2.normalizePackEnvPrefix('{"envPrefix":"BONGOS"}', 'BONGOS'), null);
1382
+ assert.equal(U2.normalizePackEnvPrefix('{"identity":{}}', 'BONGOS'), null, 'no envPrefix → nothing to correct');
1383
+ assert.equal(U2.normalizePackEnvPrefix('not json', 'BONGOS'), null);
1384
+ });
1385
+
1333
1386
  await ta('scaffoldStandaloneRepo(adopt): a default branch that is not `main` fails LOUDLY, not silently', async () => {
1334
1387
  // The rest of provisioning is main-only (git pull --ff-only origin main; the box deploy
1335
1388
  // does git reset --hard origin/main), so layering onto another branch would push somewhere
@@ -1776,6 +1829,56 @@ await ta('a failure BEFORE GitHub handed over credentials claims no App (task 10
1776
1829
  assert.ok(resolves.every((x) => !('app_slug' in x.patch)), 'no App is named when none was handed over');
1777
1830
  });
1778
1831
 
1832
+ // Task 1004573: an adopted repo brings its own config/branding.json. Mercury's said
1833
+ // envPrefix CLOUDBONGOS while the platform wrote BONGOS_*, so the project never saw
1834
+ // its credentials — and the only thing anyone was told was "did not report sign-in
1835
+ // ready". When the check fails, the runner now reads the pack and names a mismatch.
1836
+ const U = require('../scripts/gds/provision-units.js');
1837
+
1838
+ await ta('envPrefixMismatch names a pack that reads other names, and nothing else (task 1004573)', async () => {
1839
+ const m = U.envPrefixMismatch(JSON.stringify({ envPrefix: 'CLOUDBONGOS' }), 'BONGOS');
1840
+ assert.match(m, /reads settings named CLOUDBONGOS_\*, but the platform wrote BONGOS_\*/);
1841
+ assert.match(m, /set its envPrefix to "BONGOS"/);
1842
+ assert.equal(U.envPrefixMismatch(JSON.stringify({ envPrefix: 'BONGOS' }), 'BONGOS'), null, 'agreeing names say nothing');
1843
+ assert.equal(U.envPrefixMismatch(JSON.stringify({ identity: {} }), 'BONGOS'), null, 'a pack with no envPrefix claims nothing');
1844
+ assert.equal(U.envPrefixMismatch('not json', 'BONGOS'), null, 'an unreadable pack claims nothing');
1845
+ assert.equal(U.envPrefixMismatch(null, 'BONGOS'), null);
1846
+ });
1847
+
1848
+ await ta('a sign-in check that fails on a mismatched pack SAYS so (task 1004573)', async () => {
1849
+ const inst = { ...coTenant, hosting_shape: 'cloud-host', slug: 'mercury', domain: 'mercury.example.com' };
1850
+ const cmds = [], reads = [];
1851
+ const deps = {
1852
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
1853
+ convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 1, appSlug: 's' }),
1854
+ exec: (cmd) => { cmds.push(cmd); return { ok: true, stdout: '{"ok":true,"auth_configured":false}' }; },
1855
+ readFile: (p) => { reads.push(p); return JSON.stringify({ envPrefix: 'CLOUDBONGOS' }); },
1856
+ writeFile: () => ({ ok: true }),
1857
+ };
1858
+ const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
1859
+ assert.equal(r.ok, false);
1860
+ assert.match(r.error, /credentials are placed/, 'still owns what DID happen');
1861
+ assert.match(r.error, /reads settings named CLOUDBONGOS_\*, but the platform wrote/, 'and names the cause');
1862
+ assert.deepEqual(reads, [join(P.standaloneRoot(inst), 'config', 'branding.json')], 'reads the project\'s OWN pack, from its checkout');
1863
+ assert.ok(!cmds.some((c) => /branding\.json/.test(c)), 'a plain file read — no shell command carries the path');
1864
+ });
1865
+
1866
+ await ta('a sign-in check that fails with agreeing names keeps the plain message (task 1004573)', async () => {
1867
+ const inst = { ...coTenant, hosting_shape: 'cloud-host', domain: 'mercury.example.com' };
1868
+ const deps = {
1869
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
1870
+ convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 1, appSlug: 's' }),
1871
+ exec: () => ({ ok: true, stdout: '{"ok":true,"auth_configured":false}' }),
1872
+ readFile: () => JSON.stringify({ envPrefix: U.provisionedEnvPrefix() }),
1873
+ writeFile: () => ({ ok: true }),
1874
+ };
1875
+ const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
1876
+ assert.equal(r.error, 'the credentials are placed, but the project did not report sign-in ready in time');
1877
+ // an unreadable pack names nothing either
1878
+ const r2 = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, { ...deps, readFile: () => { throw new Error('ENOENT'); } });
1879
+ assert.equal(r2.error, 'the credentials are placed, but the project did not report sign-in ready in time');
1880
+ });
1881
+
1779
1882
  await ta('a PRE-conversion failure stays retryable — the narrowing spared the retry path (F23)', async () => {
1780
1883
  let claims = 0; const resolves = [];
1781
1884
  const deps = {
@@ -19,7 +19,7 @@ const read = (n) => fs.readFileSync(byName.get(n), 'utf8');
19
19
  const info = (n) => lint.lintSkillFile(read(n), { file: byName.get(n) });
20
20
 
21
21
  // The ruling, as written into the task.
22
- const LISTED_TRIMMED = ['grade-recover', 'collab-review', 'session-handoff', 'builder-claim', 'builder-sequence', 'blocker-solve', 'design', 'goal-create', 'blocker-review', 'builder-reauth', 'read-session-export', 'recall', 'worktree-clean', 'merge-mode', 'builder-setup', 'builder-key', 'bongos-feedback', 'feedback', 'builder-redteam', 'scan-before-install', 'owner-review'];
22
+ const LISTED_TRIMMED = ['grade-recover', 'collab-review', 'session-handoff', 'builder-claim', 'builder-sequence', 'blocker-solve', 'design', 'goal-create', 'blocker-review', 'builder-reauth', 'read-session-export', 'recall', 'worktree-clean', 'merge-mode', 'builder-setup', 'builder-key', 'bongos-feedback', 'builder-redteam', 'scan-before-install', 'owner-review'];
23
23
  const LISTED_AS_IS = ['strand-fix', 'builder-ship', 'builder-start', 'fix-task', 'ask-for-help', 'ship-check', 'status', 'builder-end', 'builder-release', 'builder-stage'];
24
24
  const HIDDEN = ['planning-session', 'goal-close', 'grade-sweep', 'builder-exit', 'builder-backup', 'builder-cost', 'new-project', 'design-sync', 'figma-design-sync', 'tweak'];
25
25
  const ALIASES = ['backlog-review', 'bug-triage', 'idea-triage', 'priority-session', 'goal-uat', 'goal-review', 'grade-audit', 'grader-health'];
@@ -1,39 +0,0 @@
1
- ---
2
- name: feedback
3
- description: >-
4
- Pull the latest Bongos feedback bundle (walkthrough transcript, screenshots) into this session; read-only. Triggers: "/feedback", "pull in my latest feedback", "load my bongos feedback", "I just recorded a walkthrough", "act on my screen recording".
5
- plain: >-
6
- Loads your most recent recorded walkthrough, what you said and the screenshots, into this conversation.
7
- reach-for: >-
8
- Right after you have recorded feedback on the project and want your assistant to act on it.
9
- cost: >-
10
- Free, and it changes nothing. It only reads files on your computer.
11
- ---
12
-
13
- You are pulling in the **latest Bongos feedback bundle** — a self-contained capture the user just recorded with the Bongos desktop app (BV1.R13 / [ADR 0087](../../../docs/adr/0087-bongos-app-architecture-and-handoff-contract.md)). The app turns a spoken screen walkthrough into a model-agnostic `prompt.md` (a timestamped transcript + an ask) plus a handful of screenshots, all under `~/CloudBongos/feedback/`. This skill loads that bundle so you can act on it directly — no copy-paste, no clipboard-image fiddling.
14
-
15
- ## Steps
16
-
17
- 1. **Resolve + print the latest bundle:**
18
-
19
- ```bash
20
- node scripts/gds/feedback-latest.js
21
- ```
22
-
23
- This prints the full `prompt.md` body followed by an `attached frames` list — one **absolute image path** per line. (Add `--json` if you want structured output instead.)
24
-
25
- 2. **If it says "No feedback bundle found"** — tell the user there's nothing to load yet: record a walkthrough in the Bongos app and stop the capture (that writes the bundle), then run `/feedback` again. Stop here.
26
-
27
- 3. **Read every listed frame.** For each absolute path under `attached frames`, call the **Read** tool on it so the screenshots enter your context as images. The paths are real files on disk — Read them directly; do not ask the user to paste anything.
28
-
29
- 4. **Act on the bundle.** Treat the transcript in `prompt.md` as the request and the screenshots as ground truth for what the user was looking at (the timestamps line the words up with the images). Work out what they want changed or fixed and do it. Ask a question only if you are genuinely blocked.
30
-
31
- ## Notes
32
-
33
- - **Read-only + local.** The helper only reads files under `~/CloudBongos/feedback/`; it needs no Bongos session and no network.
34
- - **"Latest" = most recently produced.** The Bongos app updates a `latest` pointer every time a capture is stopped, so this always loads the newest walkthrough. To act on an older one, point the user at the specific dir under `~/CloudBongos/feedback/`.
35
- - **The frames are file paths, never base64** — that's the whole point of the bundle format (it keeps the text prompt tiny and lets your Read tool load the images). See [ADR 0087](../../../docs/adr/0087-bongos-app-architecture-and-handoff-contract.md) for the full bundle contract.
36
-
37
- ## Files this skill touches
38
-
39
- - Runs: `node scripts/gds/feedback-latest.js` (resolves the ADR 0087 `latest` pointer itself — the app source is off-repo, ADR 0098).
@@ -1,28 +0,0 @@
1
- #!/usr/bin/env node
2
- // scripts/bongos-mcp.js — R14 launcher for the Bongos feedback MCP server.
3
- //
4
- // This is the entry point a Claude MCP config points at. The server itself
5
- // lives in bongos-app/src/mcp-server.js (bundled with the app); this thin
6
- // launcher lets it run from a repo checkout / a stable path without depending
7
- // on the app's process.
8
- //
9
- // Register it with Claude Code:
10
- // claude mcp add bongos-feedback -- node /ABSOLUTE/PATH/scripts/bongos-mcp.js
11
- //
12
- // Or in a Claude Desktop / Code MCP config:
13
- // { "mcpServers": { "bongos-feedback": {
14
- // "command": "node",
15
- // "args": ["/ABSOLUTE/PATH/scripts/bongos-mcp.js"] } } }
16
- //
17
- // Then call the get_latest_feedback tool to pull the latest bundle (prompt text
18
- // + screenshot file paths). See bongos-app/README.md for the full contract.
19
-
20
- 'use strict';
21
-
22
- const path = require('node:path');
23
- const { main } = require(path.join(__dirname, '..', 'bongos-app', 'src', 'mcp-server.js'));
24
-
25
- main().catch((err) => {
26
- process.stderr.write(`bongos-mcp: ${err.message}\n`);
27
- process.exit(1);
28
- });
@@ -1,224 +0,0 @@
1
- #!/usr/bin/env node
2
- // scripts/gds/feedback-latest.js — R13 (BV1.R13): the engine behind the
3
- // /feedback Claude Code slash command.
4
- //
5
- // Resolves the LATEST feedback bundle the Bongos app wrote to
6
- // ~/CloudBongos/feedback/ (via the shared `latest` pointer, ADR 0087) and
7
- // prints it for a Claude Code session:
8
- //
9
- // 1. the full prompt.md body (the model-agnostic transcript + ask, R11), and
10
- // 2. the ABSOLUTE path of every attached frame, on its own line, so Claude's
11
- // Read tool can load each image directly.
12
- //
13
- // This sidesteps the CLI's unreliable clipboard-image paste: Claude reads the
14
- // frames from disk by path instead of from a pasted blob.
15
- //
16
- // Usage:
17
- // node scripts/gds/feedback-latest.js # human/agent-readable dump
18
- // node scripts/gds/feedback-latest.js --json # { id, dir, promptMarkdown, imagePaths }
19
- // node scripts/gds/feedback-latest.js --base DIR # override bundle base dir (tests)
20
- //
21
- // Local + read-only: it reads files under your home dir. No GDS session, no
22
- // network — so it works on any machine the Bongos app runs on.
23
- //
24
- // resolveLatest + feedbackBaseDir are ported here from the app's
25
- // bongos-app/src/handoff.js, which no longer lives in this repo (the desktop
26
- // apps publish off-repo, ADR 0098 — task 1004356). The contract is ADR 0087's:
27
- // `latest.json` is authoritative, the `latest/` symlink is a convenience that
28
- // may be absent, and with neither the reader self-heals by scanning for the
29
- // newest bundle dir holding a prompt.md.
30
-
31
- 'use strict';
32
-
33
- const fs = require('node:fs/promises');
34
- const os = require('node:os');
35
- const path = require('node:path');
36
-
37
- const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
38
-
39
- /** Where the Bongos app writes bundles: ~/CloudBongos/feedback. */
40
- function feedbackBaseDir() {
41
- return path.join(os.homedir(), 'CloudBongos', 'feedback');
42
- }
43
-
44
- async function isBundleDir(dir) {
45
- try {
46
- return (await fs.stat(path.join(dir, 'prompt.md'))).isFile();
47
- } catch {
48
- return false;
49
- }
50
- }
51
-
52
- // The dir `latest.json` names, or null if it is missing, unreadable or stale.
53
- // Accepts the pointer by `dir` (absolute) or by `id` (a child of baseDir).
54
- async function fromPointer(baseDir) {
55
- let ptr;
56
- try {
57
- ptr = JSON.parse(await fs.readFile(path.join(baseDir, 'latest.json'), 'utf8'));
58
- } catch {
59
- return null;
60
- }
61
- if (!ptr || typeof ptr !== 'object') return null;
62
- const candidates = [];
63
- if (typeof ptr.dir === 'string' && ptr.dir) candidates.push(path.resolve(baseDir, ptr.dir));
64
- if (typeof ptr.id === 'string' && ptr.id && !/[\\/]/.test(ptr.id)) candidates.push(path.join(baseDir, ptr.id));
65
- for (const dir of candidates) if (await isBundleDir(dir)) return dir;
66
- return null;
67
- }
68
-
69
- async function fromSymlink(baseDir) {
70
- const link = path.join(baseDir, 'latest');
71
- if (!(await isBundleDir(link))) return null;
72
- try {
73
- return await fs.realpath(link);
74
- } catch {
75
- return null;
76
- }
77
- }
78
-
79
- // Self-heal: the newest child dir (by prompt.md mtime, then name) with a prompt.md.
80
- async function fromScan(baseDir) {
81
- let entries;
82
- try {
83
- entries = await fs.readdir(baseDir, { withFileTypes: true });
84
- } catch {
85
- return null;
86
- }
87
- let best = null;
88
- for (const e of entries) {
89
- if (!e.isDirectory() || e.name === 'latest') continue;
90
- const dir = path.join(baseDir, e.name);
91
- let st;
92
- try {
93
- st = await fs.stat(path.join(dir, 'prompt.md'));
94
- } catch {
95
- continue;
96
- }
97
- if (!st.isFile()) continue;
98
- if (!best || st.mtimeMs > best.mtimeMs || (st.mtimeMs === best.mtimeMs && e.name > best.name)) {
99
- best = { dir, name: e.name, mtimeMs: st.mtimeMs };
100
- }
101
- }
102
- return best && best.dir;
103
- }
104
-
105
- // The bundle's frames as absolute paths: those prompt.md references (in the
106
- // order the walkthrough cites them) that exist, then any other image in the dir.
107
- async function collectImages(dir, promptMarkdown) {
108
- const out = [];
109
- const seen = new Set();
110
- const add = (p) => {
111
- const key = process.platform === 'win32' ? p.toLowerCase() : p;
112
- if (!seen.has(key)) { seen.add(key); out.push(p); }
113
- };
114
- for (const m of promptMarkdown.matchAll(/!\[[^\]]*\]\(\s*<?([^)>]+?)>?\s*\)/g)) {
115
- const p = path.resolve(dir, m[1]);
116
- if (!IMAGE_EXT.has(path.extname(p).toLowerCase())) continue;
117
- try {
118
- if ((await fs.stat(p)).isFile()) add(p);
119
- } catch { /* referenced but absent — skip */ }
120
- }
121
- const walk = async (d, depth) => {
122
- let entries;
123
- try {
124
- entries = await fs.readdir(d, { withFileTypes: true });
125
- } catch {
126
- return;
127
- }
128
- entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
129
- for (const e of entries) {
130
- const p = path.join(d, e.name);
131
- if (e.isDirectory() && depth > 0) await walk(p, depth - 1);
132
- else if (e.isFile() && IMAGE_EXT.has(path.extname(e.name).toLowerCase())) add(p);
133
- }
134
- };
135
- await walk(dir, 1);
136
- return out;
137
- }
138
-
139
- /**
140
- * The newest feedback bundle under baseDir, or null if there is none.
141
- * @returns {Promise<null|{id:string,dir:string,promptPath:string,promptMarkdown:string,imagePaths:string[]}>}
142
- */
143
- async function resolveLatest(baseDir = feedbackBaseDir()) {
144
- const dir = (await fromPointer(baseDir)) || (await fromSymlink(baseDir)) || (await fromScan(baseDir));
145
- if (!dir) return null;
146
- const promptPath = path.join(dir, 'prompt.md');
147
- const promptMarkdown = await fs.readFile(promptPath, 'utf8');
148
- return {
149
- id: path.basename(dir),
150
- dir,
151
- promptPath,
152
- promptMarkdown,
153
- imagePaths: await collectImages(dir, promptMarkdown),
154
- };
155
- }
156
-
157
- function parseArgs(argv) {
158
- const out = { json: false, base: undefined };
159
- for (let i = 0; i < argv.length; i++) {
160
- const a = argv[i];
161
- if (a === '--json') out.json = true;
162
- else if (a === '--base') out.base = argv[++i];
163
- else if (a.startsWith('--base=')) out.base = a.slice('--base='.length);
164
- }
165
- return out;
166
- }
167
-
168
- async function main() {
169
- const args = parseArgs(process.argv.slice(2));
170
- const baseDir = args.base || feedbackBaseDir();
171
-
172
- const latest = await resolveLatest(baseDir);
173
-
174
- if (!latest) {
175
- if (args.json) {
176
- process.stdout.write(`${JSON.stringify({ ok: false, reason: 'no_bundle', baseDir })}\n`);
177
- } else {
178
- process.stdout.write(
179
- `No feedback bundle found under ${baseDir}.\n`
180
- + 'Record a walkthrough in the Bongos app and stop the capture — that '
181
- + 'writes a bundle here — then run /feedback again.\n',
182
- );
183
- }
184
- return;
185
- }
186
-
187
- if (args.json) {
188
- process.stdout.write(`${JSON.stringify({
189
- ok: true,
190
- id: latest.id,
191
- dir: latest.dir,
192
- promptPath: latest.promptPath,
193
- imagePaths: latest.imagePaths,
194
- promptMarkdown: latest.promptMarkdown,
195
- }, null, 2)}\n`);
196
- return;
197
- }
198
-
199
- const lines = [];
200
- lines.push(`# Latest Bongos feedback bundle — ${latest.id}`);
201
- lines.push(`# dir: ${latest.dir}`);
202
- lines.push('');
203
- lines.push('---- prompt.md ----');
204
- lines.push('');
205
- lines.push(latest.promptMarkdown.trimEnd());
206
- lines.push('');
207
- lines.push('---- attached frames (Read each path to see the screenshots) ----');
208
- if (latest.imagePaths.length === 0) {
209
- lines.push('(none — this session had no salient frames or markup)');
210
- } else {
211
- for (const p of latest.imagePaths) lines.push(p);
212
- }
213
- lines.push('');
214
- process.stdout.write(`${lines.join('\n')}\n`);
215
- }
216
-
217
- module.exports = { resolveLatest, feedbackBaseDir };
218
-
219
- if (require.main === module) {
220
- main().catch((err) => {
221
- process.stderr.write(`feedback-latest: ${err.message}\n`);
222
- process.exitCode = 1;
223
- });
224
- }