@bongos/core 1.20.36 → 1.20.38

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 (53) hide show
  1. package/.bongos-core.json +104 -49
  2. package/.claude/skills/backlog-review/SKILL.md +1 -1
  3. package/.claude/skills/bongos-feedback/SKILL.md +66 -0
  4. package/.claude/skills/collab-review/SKILL.md +1 -1
  5. package/.claude/skills/priority-session/SKILL.md +1 -1
  6. package/.claude/skills/session-handoff/SKILL.md +1 -1
  7. package/.claude/skills/status/SKILL.md +1 -1
  8. package/bin/bongos.js +1 -0
  9. package/clients/bongos-client/README.md +1 -1
  10. package/clients/bongos-client/bongos-client.global.js +8 -0
  11. package/clients/bongos-client/index.cjs +8 -0
  12. package/clients/bongos-client/index.d.ts +12 -1
  13. package/clients/bongos-client/index.mjs +8 -0
  14. package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
  15. package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +41 -0
  16. package/docs/adr/README.md +1 -0
  17. package/docs/api/openapi.json +188 -2
  18. package/docs/api-reference.md +12 -4
  19. package/docs/architecture.md +2 -0
  20. package/docs/copy-inventory.md +9 -9
  21. package/docs/copy-registry.json +9 -9
  22. package/docs/file-map.md +1 -0
  23. package/docs/module-api-changelog.md +4 -0
  24. package/docs/page-readings.json +2 -2
  25. package/docs/recipes/upgrading-the-core.md +2 -0
  26. package/modules/autonomy/db.js +39 -8
  27. package/modules/autonomy/fence.js +26 -4
  28. package/modules/autonomy/migrations/autonomy_003_priority_goal.sql +32 -0
  29. package/modules/autonomy/routes/autonomy.js +28 -1
  30. package/modules/hall-ui/public/gate.js +33 -4
  31. package/modules/ideas/routing.js +8 -1
  32. package/modules/platform-identity/instance-feedback.js +142 -0
  33. package/modules/platform-identity/module.json +1 -1
  34. package/modules/platform-identity/routes/sso.js +62 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +30 -0
  38. package/scripts/gds/autobongos-run.js +121 -16
  39. package/scripts/gds/feedback-send.js +131 -0
  40. package/scripts/gds/module-assess-tests.js +248 -0
  41. package/scripts/gds/update-channel.js +82 -0
  42. package/src/bongos/routes/feedback.js +110 -0
  43. package/src/bongos/routes.js +2 -0
  44. package/src/module-api.js +1 -1
  45. package/tests/autobongos_fence.mjs +62 -0
  46. package/tests/autobongos_loop.mjs +281 -0
  47. package/tests/autonomy_fence_priority.mjs +211 -0
  48. package/tests/bongos_cli.mjs +7 -1
  49. package/tests/filing_abuse_matrix.mjs +7 -2
  50. package/tests/idea_routing.mjs +1 -1
  51. package/tests/instance_feedback.mjs +327 -0
  52. package/tests/module_assess_tests.mjs +232 -0
  53. package/tests/update_channel_db.mjs +124 -0
@@ -26,6 +26,10 @@ const hubDevice = require('../hub-device');
26
26
  // The D7 applicant-profile read (task 1002972) and the echo that gates it.
27
27
  const applicantProfile = require('../applicant-profile');
28
28
  const applicationEcho = require('../application-echo');
29
+ const instanceFeedback = require('../instance-feedback');
30
+
31
+ // One limiter per hub process for POST /sso/feedback (task 1004462).
32
+ const feedbackLimiter = instanceFeedback.makeFeedbackLimiter();
29
33
 
30
34
  // Per-IP cap for POST /sso/applicant-profile — DEFENCE IN DEPTH behind the echo
31
35
  // gate, not the gate itself (task 1002972 grade advisory). The echo already
@@ -692,5 +696,63 @@ module.exports = function ssoRoutes() {
692
696
  }
693
697
  });
694
698
 
699
+ // POST /sso/feedback — a federated project relays one builder's feedback about
700
+ // Bongos itself (task 1004462). A bug becomes a 'backlog' task in the building
701
+ // version's maintenance goal; an idea becomes an inbox row. Why always backlog,
702
+ // and why the sender must be a witnessed member: ../instance-feedback.js.
703
+ //
704
+ // rank: public — authenticated by client_id + client_secret, not a builder rank.
705
+ // The per-project and per-sender caps run after the client check, so an
706
+ // unauthenticated caller cannot spend a real project's allowance.
707
+ router.post('/sso/feedback', async (req, res) => {
708
+ if (validateOrRespond(req, res, {
709
+ client_id: { required: true, type: 'string', maxLength: 200 },
710
+ client_secret: { required: true, type: 'string', maxLength: 512 },
711
+ github_id: { required: true },
712
+ message: { required: true, type: 'string', maxLength: instanceFeedback.MESSAGE_MAX },
713
+ kind: { type: 'string', enum: Object.keys(instanceFeedback.FEEDBACK_KINDS) },
714
+ context: {},
715
+ })) return;
716
+ const b = req.body || {};
717
+ try {
718
+ const client = await pi.getClient(b.client_id, { pool });
719
+ if (!client || client.status !== 'active' || !pi.clientSecretValid(client, b.client_secret)) {
720
+ return res.fail('invalid_client', 401);
721
+ }
722
+ if (!pi.isNumericGithubId(b.github_id)) return res.fail('invalid_github_id', 400);
723
+ if (!String(b.message).trim()) return res.fail('message_required', 400);
724
+ const membership = await pi.getMembership(client.client_id, b.github_id, { pool });
725
+ if (!pi.membershipBacksRollup(membership)) return res.fail('membership_required', 409);
726
+ const allowed = feedbackLimiter.take(client.client_id, String(b.github_id));
727
+ if (!allowed.ok) {
728
+ res.set('Retry-After', String(allowed.retryAfter));
729
+ return res.fail('rate_limited', 429, { scope: `feedback-${allowed.scope}`, retry_after_seconds: allowed.retryAfter });
730
+ }
731
+ const landing = await instanceFeedback.fileInstanceFeedback({
732
+ client,
733
+ githubId: String(b.github_id),
734
+ message: b.message,
735
+ kind: b.kind || 'bug',
736
+ context: b.context && typeof b.context === 'object' ? b.context : {},
737
+ }, {
738
+ capture: (...a) => api.resolve('ideas.capture')(...a),
739
+ getAccount: (id) => pi.getAccount(id, { pool }),
740
+ hubBuilderId: async (id) => {
741
+ const { rows } = await pool.query('SELECT id FROM builders WHERE github_id = $1 LIMIT 1', [id]);
742
+ return rows[0] ? rows[0].id : null;
743
+ },
744
+ });
745
+ res.status(201).json({ ok: true, ...landing });
746
+ } catch (err) {
747
+ // No building version / no maintenance goal: the hub cannot take a bug right
748
+ // now. Say so rather than 500, so the CLI can tell the builder to retry later.
749
+ if (err && (err.code === 'NO_BUILDING_VERSION' || err.code === 'NO_MAINTENANCE_GOAL')) {
750
+ return res.fail('feedback_unavailable', 503, { reason: err.code });
751
+ }
752
+ log.error('[platform-identity] /sso/feedback', err);
753
+ res.fail('feedback_failed', { status: 500, message: 'internal error' });
754
+ }
755
+ });
756
+
695
757
  return router;
696
758
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.36",
3
+ "version": "1.20.38",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.36",
9
+ "version": "1.20.38",
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.20.36",
3
+ "version": "1.20.38",
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",
@@ -8303,5 +8303,35 @@
8303
8303
  "id": "1004450",
8304
8304
  "text": "The deploy page now explains why an update stopped in plain words, says when it will try again, and offers a one-click fix when the only thing in the way is your own update setting. You can also see and change how far a projec"
8305
8305
  }
8306
+ ],
8307
+ "1.20.37": [
8308
+ {
8309
+ "id": "1003791",
8310
+ "text": "A module's own tests can now be run and scored: the store records what share of them pass, and a module with no tests shows as 'no data yet' instead of failing."
8311
+ },
8312
+ {
8313
+ "id": "1004406",
8314
+ "text": "The unattended runner stops restarting every time anyone merges: it now updates its code in place and only restarts when its own code has changed."
8315
+ }
8316
+ ],
8317
+ "1.20.38": [
8318
+ {
8319
+ "id": "1004468",
8320
+ "text": "The nightly auto-update now follows the update rule you set on /deploy for each project, instead of a file on the server."
8321
+ },
8322
+ {
8323
+ "id": "1004453",
8324
+ "text": "When you start Autobongos from the Gate page you can now pick a priority goal: the runner works that goal first and moves to the other allowed goals only when it runs out of work there."
8325
+ },
8326
+ {
8327
+ "id": "1004472",
8328
+ "text": "Fixes the broken automatic check on main that was stopping everyone's finished work from landing."
8329
+ },
8330
+ {
8331
+ "text": "When a builder complains that Bongos isn't working, their assistant now reports it to the Bongos team by itself, so every frustration becomes something on the fix list."
8332
+ },
8333
+ {
8334
+ "text": "Builders on any Cloud Bongos project can now send feedback about Bongos itself with one command, and it arrives with the Bongos team as a task waiting for approval."
8335
+ }
8306
8336
  ]
8307
8337
  }
@@ -106,6 +106,15 @@ function record(entry) {
106
106
  return row;
107
107
  }
108
108
 
109
+ // pickedFrom — the row fields that say where a pick came from (task 1004453).
110
+ // `priority` is true only when the fence named a priority goal AND this pick came
111
+ // from it, so "the priority goal ran dry and the runner moved on" reads as
112
+ // priority:false beside the goal it moved on to.
113
+ function pickedFrom(goalId, gate) {
114
+ const p = gate && gate.priorityGoal != null ? gate.priorityGoal : null;
115
+ return { priority: p !== null && Number(goalId) === p };
116
+ }
117
+
109
118
  // pickTask — the allowlisted goals, in order, through sequence.js's halt matrix.
110
119
  // The matrix is reused rather than re-derived: it already refuses thin specs,
111
120
  // protected paths, judgement kinds and unmet dependencies, from facts on the row.
@@ -359,7 +368,7 @@ const MAX_CLAIM_ATTEMPTS = 5;
359
368
  const LIMIT_UNKNOWN_RESET_MS = 30 * 60 * 1000;
360
369
 
361
370
  function newRunnerState() {
362
- return { setAside: new Map(), queueGate: null, limitUntil: null };
371
+ return { setAside: new Map(), queueGate: null, limitUntil: null, diskHead: null };
363
372
  }
364
373
  const RUNNER_STATE = newRunnerState();
365
374
 
@@ -479,7 +488,7 @@ async function iteration(opts, deps = {}) {
479
488
  // looks like, and it must still reach the failure ladder (task 1004179).
480
489
  if (lastRefusal) return lastRefusal;
481
490
  return log({
482
- event: 'nothing_claimable', goals: gate.goals,
491
+ event: 'nothing_claimable', goals: gate.goals, priority_goal: gate.priorityGoal ?? null,
483
492
  // Carried so the cadence can tell an EMPTY queue from one where every task
484
493
  // halts — the reasons are the matrix's own codes, safe to print.
485
494
  skipped: (got.skipped || []).map((sk) => ({ id: sk.id, goal_id: sk.goal_id, reasons: (sk.reasons || []).map((r) => r.code) })),
@@ -501,10 +510,10 @@ async function iteration(opts, deps = {}) {
501
510
  task: candidate, goals: gate.goals, protectedHits: matchProtected(candidate.touches || []),
502
511
  });
503
512
  if (!taskGate.go) {
504
- return log({ event: 'fenced', code: taskGate.code, reason: taskGate.reason, task_id: candidate.id, goal_id: got.goalId, globs: taskGate.globs || [] });
513
+ return log({ event: 'fenced', code: taskGate.code, reason: taskGate.reason, task_id: candidate.id, goal_id: got.goalId, ...pickedFrom(got.goalId, gate), globs: taskGate.globs || [] });
505
514
  }
506
515
 
507
- if (opts.dryRun) return log({ event: 'dry_run', task_id: candidate.id, title: candidate.title, goal_id: got.goalId });
516
+ if (opts.dryRun) return log({ event: 'dry_run', task_id: candidate.id, title: candidate.title, goal_id: got.goalId, ...pickedFrom(got.goalId, gate) });
508
517
 
509
518
  // 3. The task's own working tree.
510
519
  const name = deps.worktreeName ? deps.worktreeName(candidate.id) : worktreeName(candidate.id);
@@ -566,6 +575,7 @@ async function iteration(opts, deps = {}) {
566
575
 
567
576
  const row = {
568
577
  event: 'worked', task_id: task.id, title: task.title, goal_id: picked.goalId,
578
+ ...pickedFrom(picked.goalId, gate),
569
579
  worktree: wtName,
570
580
  outcome: verdict.outcome, reason: verdict.reason,
571
581
  session_id: res.sessionId || null, duration_s: Math.round((Date.now() - started) / 1000),
@@ -666,13 +676,16 @@ function heartbeatPath() {
666
676
  // output, reddening `unit` on main and blocking every PR. Use a regular FILE as the
667
677
  // parent directory instead: mkdir under it is ENOTDIR immediately, everywhere, and it
668
678
  // does not depend on the caller's permissions.
669
- function writeHeartbeat(now = Date.now()) {
679
+ function writeHeartbeat(now = Date.now(), runnerState = RUNNER_STATE) {
670
680
  try {
671
681
  const p = heartbeatPath();
672
682
  fs.mkdirSync(path.dirname(p), { recursive: true });
673
683
  // `head` is what this process is RUNNING, not what is on disk (task 1004374).
674
684
  // Alive and alive-on-stale-code look identical from a check-in age alone.
675
- fs.writeFileSync(p, JSON.stringify({ pid: process.pid, at: now, head: LOADED_HEAD }));
685
+ // `disk_head` appears only when the checkout was refreshed in place past what
686
+ // is running (task 1004406): "up to date on disk, old supervisor" is visible.
687
+ const disk = runnerState && runnerState.diskHead && runnerState.diskHead !== LOADED_HEAD ? runnerState.diskHead : undefined;
688
+ fs.writeFileSync(p, JSON.stringify({ pid: process.pid, at: now, head: LOADED_HEAD, ...(disk ? { disk_head: disk } : {}) }));
676
689
  return true;
677
690
  } catch (_) { return false; }
678
691
  }
@@ -851,10 +864,79 @@ async function codeDrifted(deps = {}) {
851
864
  // the comparison untestable in isolation.
852
865
  const mine = deps.loadedHead ? await deps.loadedHead(deps) : await loadedHead(deps);
853
866
  if (!mine) return null;
867
+ // Measured from what is on DISK once the checkout has been refreshed in place
868
+ // (task 1004406), or every loop after a refresh would see the same drift again.
869
+ const base = ((deps.state || RUNNER_STATE).diskHead) || mine;
854
870
  let tip = null;
855
871
  try { tip = await runFetchHead(deps); } catch (_) { return null; }
856
872
  if (!tip) return null;
857
- return tip !== mine ? { from: mine.slice(0, 8), to: String(tip).slice(0, 8) } : false;
873
+ return tip !== base ? { from: base.slice(0, 8), to: String(tip).slice(0, 8) } : false;
874
+ }
875
+
876
+ // ── pull always, restart rarely (task 1004406) ──────────────────────────────
877
+ //
878
+ // Owner-approved 2026-09-30. Main moves every time ANY session lands, and a
879
+ // relaunch per merge meant a busy day of restarts (four in fourteen minutes on
880
+ // 2026-09-29). But a restart only refreshes code THIS process holds in memory:
881
+ // every worker, and every claim.js / worktree.js / release.js the loop shells
882
+ // out to, is a fresh process that runs whatever is on disk. So on drift the
883
+ // checkout is updated in place, and the process exits for the wrapper only when
884
+ // a change touches code it has actually loaded.
885
+ //
886
+ // SUPERVISOR_PATH_RES catch the supervisor's own files even before they are
887
+ // required (autobongos-verify.js is loaded lazily on the first ship), so a change
888
+ // to a file the loop is ABOUT to load cannot mix old callers with a new callee.
889
+ const SUPERVISOR_PATH_RES = [/^scripts\/gds\/autobongos-[^/]+$/, /^modules\/autonomy\//];
890
+
891
+ // The repo files this process has loaded, repo-relative with forward slashes.
892
+ function loadedFiles() {
893
+ const root = REPO_ROOT + path.sep;
894
+ const nm = path.sep + 'node_modules' + path.sep;
895
+ return Object.keys(require.cache)
896
+ .filter((p) => p.startsWith(root) && !p.includes(nm))
897
+ .map((p) => path.relative(REPO_ROOT, p).split(path.sep).join('/'));
898
+ }
899
+
900
+ // The changed files that force a restart: ones this process holds, or ones under
901
+ // the supervisor's own paths. Everything else is picked up by the next child.
902
+ function supervisorTouched(changed = [], loaded = loadedFiles()) {
903
+ const held = new Set(loaded);
904
+ return changed.filter((f) => held.has(f) || SUPERVISOR_PATH_RES.some((re) => re.test(f)));
905
+ }
906
+
907
+ // refreshCheckout — the wrapper's fetch + reset, done from inside the process.
908
+ //
909
+ // THE MAIN CHECKOUT ONLY. It runs `git reset --hard`, and a linked worktree is a
910
+ // claim somebody is building in; resetting one would destroy their work. The
911
+ // check compares git-dir to git-common-dir, which are equal only in the main
912
+ // checkout. Armed only by main() (`opts.refreshInPlace`), so a test driving
913
+ // forever() can never reach it — in CI that reset would wipe the code under test.
914
+ //
915
+ // The diff is from the LOADED commit, not the last refresh, so a supervisor
916
+ // change that landed several refreshes ago still forces the restart it needs.
917
+ async function refreshCheckout(deps = {}) {
918
+ const runCmd = deps.run || run;
919
+ const git = (args) => runCmd('git', args, { cwd: REPO_ROOT });
920
+ try {
921
+ const [gd, cd] = await Promise.all([git(['rev-parse', '--git-dir']), git(['rev-parse', '--git-common-dir'])]);
922
+ if (!gd.ok || !cd.ok) return { ok: false, reason: 'could not tell which checkout this is' };
923
+ if (path.resolve(REPO_ROOT, String(gd.stdout).trim()) !== path.resolve(REPO_ROOT, String(cd.stdout).trim())) {
924
+ return { ok: false, reason: 'not the main checkout — refusing to reset a linked worktree' };
925
+ }
926
+ const loaded = deps.loadedHead ? await deps.loadedHead(deps) : await loadedHead(deps);
927
+ if (!loaded) return { ok: false, reason: 'the running commit is unknown' };
928
+ if (!(await git(['fetch', '--quiet', 'origin', 'main'])).ok) return { ok: false, reason: 'fetch failed' };
929
+ const fh = await git(['rev-parse', 'FETCH_HEAD']);
930
+ const to = fh.ok ? String(fh.stdout).trim() : '';
931
+ if (!/^[0-9a-f]{40}$/.test(to)) return { ok: false, reason: 'could not read the fetched commit' };
932
+ const diff = await git(['diff', '--name-only', loaded, to]);
933
+ if (!diff.ok) return { ok: false, reason: 'could not diff the running commit against main' };
934
+ if (!(await git(['reset', '--quiet', '--hard', to])).ok) return { ok: false, reason: 'reset failed' };
935
+ const changed = String(diff.stdout).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
936
+ return { ok: true, to, changed };
937
+ } catch (e) {
938
+ return { ok: false, reason: e && e.message ? e.message : String(e) };
939
+ }
858
940
  }
859
941
 
860
942
  // The head of origin/main, or null. `git ls-remote` rather than `fetch`: it moves
@@ -932,7 +1014,9 @@ async function forever(opts, deps = {}) {
932
1014
  // reports itself anyway: the hall reads the AGE of the last check-in, so a
933
1015
  // failed beat shows up as silence, which is the signal.
934
1016
  const beat = async (fields) => {
935
- try { beatFile(); } catch (_) { /* a runner that cannot write its file still runs */ }
1017
+ // The runner state is passed, not defaulted: the file names `disk_head` from
1018
+ // it (task 1004406), and an injected state must reach the beat that reports it.
1019
+ try { beatFile(Date.now(), deps.state || RUNNER_STATE); } catch (_) { /* a runner that cannot write its file still runs */ }
936
1020
  try { await beatServer(fields, deps); } catch (_) { /* and one that cannot reach the instance still runs */ }
937
1021
  };
938
1022
  // The running commit, read ONCE, BEFORE the first beat (task 1004397). It used
@@ -969,7 +1053,7 @@ async function forever(opts, deps = {}) {
969
1053
  // task 1234" from "quiet because it is dead". A worker may hold the loop for
970
1054
  // ninety minutes, and without this every honest hour of work looks like a
971
1055
  // corpse.
972
- await beat({ mode: state.mode, last_event: row.event, working_task_id: Number(row.task_id) || undefined });
1056
+ await beat({ mode: state.mode, last_event: row.event, working_task_id: Number(row.task_id) || undefined, working_goal_id: Number(row.goal_id) || undefined });
973
1057
  const seen = cadence.classifyEvent(row);
974
1058
  // A hold that names its reset SECOND waits until that second (task 1004454).
975
1059
  // classifyEvent carries it as an absolute epoch; the cadence has no clock of
@@ -993,14 +1077,35 @@ async function forever(opts, deps = {}) {
993
1077
  // spends nearly all of its time and an upgrade should not sit behind it.
994
1078
  const upMs = (deps.now ? deps.now() : Date.now()) - Date.parse(RUNNER_STARTED_AT);
995
1079
  const mayUpgrade = upMs >= MIN_UPTIME_BEFORE_UPGRADE_MS;
996
- const drift = mayUpgrade
1080
+ let drift = mayUpgrade
997
1081
  ? (deps.codeDrifted ? await deps.codeDrifted(deps) : await codeDrifted(deps))
998
1082
  : false;
999
1083
  if (drift) {
1000
- log({ event: 'upgrade_exit', from: drift.from, to: drift.to,
1001
- reason: `main moved from ${drift.from} to ${drift.to} — exiting so the wrapper can relaunch on the new code` });
1002
- await beat({ mode: state.mode, last_event: 'upgrade_exit' });
1003
- return UPGRADE_EXIT_CODE;
1084
+ // Pull always, restart rarely (task 1004406). Only main() arms the refresh.
1085
+ let why = 'exiting so the wrapper can relaunch on the new code';
1086
+ if (opts.refreshInPlace) {
1087
+ const refreshed = await (deps.refreshCheckout || refreshCheckout)(deps);
1088
+ if (refreshed && refreshed.ok) {
1089
+ const touched = supervisorTouched(refreshed.changed, deps.loadedFiles ? deps.loadedFiles() : loadedFiles());
1090
+ if (!touched.length) {
1091
+ (deps.state || RUNNER_STATE).diskHead = refreshed.to;
1092
+ log({ event: 'code_refreshed', from: drift.from, to: refreshed.to.slice(0, 8), changed: refreshed.changed.length,
1093
+ reason: `main moved from ${drift.from} to ${refreshed.to.slice(0, 8)} — checkout updated in place; none of the ${refreshed.changed.length} changed file(s) is code this process runs, so no restart` });
1094
+ await beat({ mode: state.mode, last_event: 'code_refreshed' });
1095
+ drift = null;
1096
+ } else {
1097
+ const more = touched.length > 5 ? `, +${touched.length - 5} more` : '';
1098
+ why = `it changed code this process runs (${touched.slice(0, 5).join(', ')}${more}) — exiting so the wrapper can relaunch on it`;
1099
+ }
1100
+ } else {
1101
+ why = `the in-place refresh did not run (${(refreshed && refreshed.reason) || 'no result'}) — exiting so the wrapper's own refresh can`;
1102
+ }
1103
+ }
1104
+ if (drift) {
1105
+ log({ event: 'upgrade_exit', from: drift.from, to: drift.to, reason: `main moved from ${drift.from} to ${drift.to} — ${why}` });
1106
+ await beat({ mode: state.mode, last_event: 'upgrade_exit' });
1107
+ return UPGRADE_EXIT_CODE;
1108
+ }
1004
1109
  }
1005
1110
 
1006
1111
  const { waited, jumped } = await wait(state.waitS, { ...deps, beat }, opts.cadence);
@@ -1015,7 +1120,7 @@ async function main(argv) {
1015
1120
  // it lived on the machine being fenced; the database owns that now, and an empty
1016
1121
  // `--goal` means "every allowlisted goal" rather than "anything claimable". The
1017
1122
  // refusal did not disappear — it moved somewhere the runner host cannot edit.
1018
- if (opts.forever) return forever(opts);
1123
+ if (opts.forever) return forever({ ...opts, refreshInPlace: true });
1019
1124
 
1020
1125
  // The one-shot path, kept for an operator running a single task by hand. It
1021
1126
  // still reconciles first: a leftover claim is a leftover claim whoever is
@@ -1043,5 +1148,5 @@ module.exports = {
1043
1148
  worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
1044
1149
  heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
1045
1150
  isRunnerTree, RUNNER_TREE_RE, postHeartbeat, RUNNER_STARTED_AT, failureReason,
1046
- newRunnerState, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
1151
+ newRunnerState, loadedFiles, supervisorTouched, refreshCheckout, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
1047
1152
  };
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ // Send feedback about Bongos itself to the Cloud Bongos maintainers (task 1004462).
3
+ //
4
+ // Usage:
5
+ // bongos feedback "the claim command hung for two minutes"
6
+ // bongos feedback --idea "let me filter the task list by goal"
7
+ // bongos feedback --message-file notes.md
8
+ // [--json]
9
+ //
10
+ // Not the /feedback skill, which loads a locally recorded walkthrough into a
11
+ // session. This SENDS: it posts to the builder's OWN project (POST /feedback,
12
+ // src/bongos/routes/feedback.js), which relays it to the hub. A bug lands on the
13
+ // hub as a 'backlog' task in the maintenance goal awaiting a maintainer's nod; an
14
+ // idea lands in the hub's idea inbox. Only the typed text and version stamps
15
+ // travel — never code, a transcript or file contents.
16
+
17
+ 'use strict';
18
+
19
+ const os = require('node:os');
20
+ const { cliClient, requireSession, argText, hasFlag, errCode, errDetails, cliExit } = require('./cli-lib');
21
+
22
+ const USAGE = [
23
+ 'usage:',
24
+ ' bongos feedback "what went wrong" report a problem with Bongos',
25
+ ' bongos feedback --idea "what you wish for" suggest an improvement',
26
+ ' bongos feedback --message-file notes.md send a longer note from a file',
27
+ '',
28
+ 'Goes to the Cloud Bongos maintainers. Only the text you type and the Bongos',
29
+ 'versions you run are sent — no code, chat or files from your project.',
30
+ ].join('\n');
31
+
32
+ // The CLI release, as `bongos version` reports it: config/bongos-cli.json where
33
+ // the core checkout carries it (an instance does not — task 1824), else the
34
+ // package version. Read from disk, never required, so a vanilla core still runs.
35
+ function cliVersion() {
36
+ const fs = require('node:fs');
37
+ const path = require('node:path');
38
+ for (const rel of ['../../config/bongos-cli.json', '../../package.json']) {
39
+ try {
40
+ const f = path.join(__dirname, rel);
41
+ if (fs.existsSync(f)) { const v = JSON.parse(fs.readFileSync(f, 'utf8')).version; if (v) return v; }
42
+ } catch { /* try the next */ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ // The message: --message / --message-file, else every positional word joined.
48
+ function readMessage(args) {
49
+ if (args.includes('--message') || args.includes('--message-file')) return argText('--message', args);
50
+ return args.filter((a) => !a.startsWith('--')).join(' ');
51
+ }
52
+
53
+ // Every report goes through the transcript-grade secret scrubber before it leaves
54
+ // this machine (task 1004467). An assistant may file unasked when a builder vents,
55
+ // so nobody reviews the text first — the scrubber is the check that does not
56
+ // depend on the assistant's judgment. FAIL CLOSED: no scrubber, nothing is sent.
57
+ // It also strips the builder's environment secrets by value, whatever their shape.
58
+ function scrubReport(message, env = process.env) {
59
+ let scrub;
60
+ try { scrub = require('../../modules/security/secret-scrub.js'); } catch { return null; }
61
+ const text = scrub.scrubSecrets(message, scrub.collectEnvSecrets(env));
62
+ const removed = text.split(scrub.REDACTED).length - message.split(scrub.REDACTED).length;
63
+ return { text, removed };
64
+ }
65
+
66
+ // Plain-language lines for each refusal the route can give.
67
+ const REFUSALS = {
68
+ message_required: 'Nothing to send — add your feedback after the command, in quotes.',
69
+ validation_failed: 'That could not be sent as written — keep it under 4,000 characters, or split it into two.',
70
+ not_hub_connected: 'This project is not connected to Cloud Bongos, so feedback cannot be relayed from here.\n'
71
+ + 'Report it to the Bongos maintainers on the Bongos core repository instead.',
72
+ membership_required: 'Cloud Bongos does not yet know you as a member of this project.\n'
73
+ + 'Sign in to this project once through Cloud Bongos in a browser, then try again.',
74
+ rate_limited: 'Too much feedback from you or this project in the last hour. Try again later.',
75
+ feedback_unavailable: 'Cloud Bongos is not taking bug reports right now (no version is being built). Try again later.',
76
+ hub_unreachable: 'Could not reach Cloud Bongos. Check your connection and try again.',
77
+ };
78
+
79
+ async function main(argv = process.argv.slice(2)) {
80
+ if (hasFlag('--help', argv) || hasFlag('-h', argv)) { console.log(USAGE); return 0; }
81
+ const json = hasFlag('--json', argv);
82
+ const kind = hasFlag('--idea', argv) ? 'idea' : 'bug';
83
+ const message = String(readMessage(argv) || '').trim();
84
+ if (!message) { console.error(USAGE); return 2; }
85
+
86
+ const scrubbed = scrubReport(message);
87
+ if (!scrubbed) {
88
+ console.error('Feedback was not sent: the secret filter that checks every report is missing from this install.');
89
+ return 1;
90
+ }
91
+
92
+ await requireSession();
93
+ const api = await cliClient();
94
+ const res = await api.request('POST', '/feedback', {
95
+ body: { message: scrubbed.text, kind, context: { cli_version: cliVersion(), os: `${os.platform()} ${os.release()}` } },
96
+ });
97
+ if (scrubbed.removed > 0 && !json) {
98
+ console.log(`Removed ${scrubbed.removed} secret-looking value(s) from the report before sending.`);
99
+ }
100
+
101
+ if (json) { console.log(JSON.stringify(res.data, null, 2)); return res.ok ? 0 : 1; }
102
+ if (!res.ok) {
103
+ const code = errCode(res.data);
104
+ const details = errDetails(res.data) || {};
105
+ let line = REFUSALS[code] || `Feedback was not sent (${code || `HTTP ${res.status}`}).`;
106
+ if (code === 'rate_limited' && details.retry_after_seconds) {
107
+ line += ` (in about ${Math.ceil(details.retry_after_seconds / 60)} min)`;
108
+ }
109
+ console.error(line);
110
+ return 1;
111
+ }
112
+ const d = res.data || {};
113
+ if (d.landed === 'task') {
114
+ console.log(`Thanks — sent to the Cloud Bongos maintainers as task ${d.task_id}.`);
115
+ console.log('It waits for a maintainer to approve it before anyone works on it.');
116
+ if (d.hub_origin) console.log(`${String(d.hub_origin).replace(/\/+$/, '')}/builders#/task/${d.task_id}`);
117
+ } else {
118
+ console.log(`Thanks — your idea is in the Cloud Bongos idea inbox (idea ${d.idea_id}) for the maintainers to review.`);
119
+ }
120
+ return 0;
121
+ }
122
+
123
+ if (require.main === module) {
124
+ // cliExit, never process.exit, after a request (task 1002734 — the Windows abort).
125
+ main().then((code) => cliExit(code), (err) => {
126
+ console.error(`Feedback was not sent: ${err && err.message ? err.message : err}`);
127
+ cliExit(1);
128
+ });
129
+ }
130
+
131
+ module.exports = { main, readMessage, REFUSALS, scrubReport };