@bongos/core 1.24.77 → 1.24.79

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 (48) hide show
  1. package/.bongos-core.json +91 -46
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +12 -1
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +15 -0
  8. package/docs/api/openapi.json +209 -4
  9. package/docs/api-reference.md +7 -3
  10. package/docs/copy-inventory.md +109 -92
  11. package/docs/copy-registry.json +291 -108
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +2 -0
  14. package/docs/page-readings.json +111 -97
  15. package/docs/recipes/autobongos-runner-setup.md +148 -0
  16. package/docs/recipes/autobongos-windows-host.md +10 -7
  17. package/modules/autonomy/cadence.js +3 -0
  18. package/modules/autonomy/db.js +86 -7
  19. package/modules/autonomy/fence.js +20 -5
  20. package/modules/autonomy/migrations/autonomy_006_runner_device.sql +45 -0
  21. package/modules/autonomy/module.json +2 -1
  22. package/modules/autonomy/routes/autonomy.js +165 -3
  23. package/modules/autonomy/runner-health.js +27 -0
  24. package/modules/autonomy/skills/autobongos-setup/SKILL.md +86 -0
  25. package/modules/hall-ui/public/commands-lib.js +1 -1
  26. package/modules/hall-ui/public/gate.js +12 -5
  27. package/modules/hall-ui/public/runner-button.css +55 -0
  28. package/modules/hall-ui/public/runner-button.js +187 -0
  29. package/modules/hall-ui/public/settings-autobongos.js +349 -63
  30. package/modules/hall-ui/public/settings.html +23 -10
  31. package/modules/hall-ui/records/runner-button.md +16 -0
  32. package/package-lock.json +2 -2
  33. package/package.json +1 -1
  34. package/release-notes.json +20 -0
  35. package/scripts/bongos/autobongos-host.js +35 -6
  36. package/scripts/bongos/autobongos-run.js +18 -3
  37. package/scripts/bongos/claude-materialize.js +20 -1
  38. package/scripts/bongos/stranded-rows.js +39 -2
  39. package/scripts/hall-preview/server.js +7 -0
  40. package/src/module-api.js +1 -1
  41. package/tests/autobongos_host.mjs +46 -0
  42. package/tests/autonomy_config_idle.mjs +9 -4
  43. package/tests/autonomy_runner_device.mjs +444 -0
  44. package/tests/autonomy_runner_identity.mjs +3 -1
  45. package/tests/claude_materialize.mjs +74 -0
  46. package/tests/hall_runner_button.mjs +467 -0
  47. package/tests/hall_runner_panel_connect.mjs +73 -0
  48. package/tests/stranded_rows.mjs +50 -0
@@ -8,6 +8,7 @@
8
8
  // node scripts/bongos/autobongos-host.js status [--json]
9
9
  // node scripts/bongos/autobongos-host.js start | stop
10
10
  // node scripts/bongos/autobongos-host.js install | uninstall
11
+ // node scripts/bongos/autobongos-host.js digest [--since 24h] # the night, in five answers
11
12
  // node scripts/bongos/autobongos-host.js run # what the start entry runs
12
13
  //
13
14
  // WHY. The runner could only be hosted one way: a Windows scheduled task
@@ -73,16 +74,31 @@ const LOG_ROTATE_BYTES = 20 * 1024 * 1024;
73
74
  function runnerHome(env = process.env, home = os.homedir(), repoRoot = REPO_ROOT) {
74
75
  if (env.AUTOBONGOS_HOME) return path.resolve(env.AUTOBONGOS_HOME);
75
76
  if (path.basename(repoRoot) === 'repo' && isRunnerCopy(repoRoot)) return path.dirname(repoRoot);
76
- return path.join(home, '.cloudbongos-runner');
77
+ return path.join(home, RUNNER_DIR);
77
78
  }
78
79
 
79
- // hostPaths — every file this program owns, from one root. `config` is handed to
80
- // the supervisor as BONGOS_CONFIG_ROOT, which is what keeps its login apart.
80
+ // The runner's folder in the person's home. Named once: the read-deny rule that
81
+ // keeps agents out of the runner's login names this same folder
82
+ // (scripts/bongos/claude-materialize.js RUNNER_CONFIG_DENY), and a test holds the
83
+ // two together.
84
+ const RUNNER_DIR = '.cloudbongos-runner';
85
+
86
+ // hostPaths — every file this program owns. `configRoot` is handed to the
87
+ // supervisor as BONGOS_CONFIG_ROOT, which is what keeps its login apart.
88
+ //
89
+ // THE LOGIN LIVES IN ONE PLACE ON EVERY DEVICE (task 1005186): configRoot is always
90
+ // <home>/.cloudbongos-runner/config, WHATEVER AUTOBONGOS_HOME says. That variable
91
+ // moves the runner's copy of the project, its logs and its lock; it does not move
92
+ // the login. The reason is the deny rule: agents are refused reads under exactly
93
+ // that folder, and a login that could be put somewhere else by an environment
94
+ // variable would be a login the rule does not cover — on precisely the device
95
+ // someone had customised. One runner per device already, so there is nothing a
96
+ // second config folder would be for.
81
97
  function hostPaths(env = process.env, home = os.homedir()) {
82
98
  const root = runnerHome(env, home);
83
99
  return {
84
100
  root,
85
- configRoot: path.join(root, 'config'),
101
+ configRoot: path.join(home, RUNNER_DIR, 'config'),
86
102
  logDir: path.join(root, 'logs'),
87
103
  log: path.join(root, 'logs', 'autobongos.log'),
88
104
  lock: path.join(root, 'host.json'),
@@ -755,6 +771,12 @@ async function waitForCheckIn({ session, fetchImpl = fetch, timeoutMs = 90000, s
755
771
  return false;
756
772
  }
757
773
 
774
+ // The digest CLI and its arguments, for `digest`: the same file a builder would
775
+ // run by hand, with whatever they passed handed through.
776
+ function digestArgv(rest = []) {
777
+ return [path.join(REPO_ROOT, 'scripts', 'bongos', 'autobongos-digest.js'), ...rest];
778
+ }
779
+
758
780
  // ── the commands ───────────────────────────────────────────────────────────
759
781
 
760
782
  async function runCommand({ paths = hostPaths() } = {}) {
@@ -819,6 +841,13 @@ async function main(argv) {
819
841
  console.log('Whether it is WORKING is on its card in Settings: the card reads its check-ins.');
820
842
  return 0;
821
843
  }
844
+ if (cmd === 'digest') {
845
+ // The runner's run log lives under ITS config root, so the digest has to be
846
+ // run with that root or it reads the builder's own (empty) one and reports a
847
+ // missing log (task 1005097).
848
+ const r = spawnSync(process.execPath, digestArgv(argv.slice(1)), { stdio: 'inherit', env: supervisorEnv(process.env, paths, dirName) });
849
+ return r.status === null ? 1 : r.status;
850
+ }
822
851
  if (cmd === 'install') {
823
852
  const e = install({ paths });
824
853
  console.log(e.kind === 'none' ? 'This system has no start-at-sign-in entry yet; use start after each restart.' : 'The runner now starts whenever you sign in to this device.');
@@ -842,7 +871,7 @@ async function main(argv) {
842
871
  for (const line of stopReport(result, { installed })) console.log(line);
843
872
  return result.outcome === 'still_running' || (cmd === 'uninstall' && installed) ? 1 : 0;
844
873
  }
845
- console.error('usage: autobongos-host.js setup --code <code> --api-base <url> | status [--json] | start | stop | install | uninstall | run');
874
+ console.error('usage: autobongos-host.js setup --code <code> --api-base <url> | status [--json] | start | stop | install | uninstall | digest [--since 24h] | run');
846
875
  return 2;
847
876
  }
848
877
 
@@ -865,5 +894,5 @@ module.exports = {
865
894
  pidAlive, lockHolder, wantsAwake, keepAwakeCommand, makeKeepAwake, runLoop,
866
895
  windowsRunArgv, windowsRunCommand, windowsStartSpawn, legacyBootTaskInstalled, launchdPlist,
867
896
  stopRunner, stopReport, exchangeCode, claudeState, waitForCheckIn,
868
- signIn, cleanBase, install, removeStartEntry, installedState, status,
897
+ signIn, cleanBase, install, removeStartEntry, installedState, status, digestArgv,
869
898
  };
@@ -455,12 +455,18 @@ function worktreeName(taskId) {
455
455
  // A failure of EITHER read returns the shape that refuses. An unreachable
456
456
  // instance and a thrown kill switch are different facts, but they license the
457
457
  // same action: do nothing.
458
+ // THIS DEVICE is named on the read (task 1005094): the server folds the device's
459
+ // own Run switch into the answer, so a builder's Stop on this machine arrives as
460
+ // `enabled: false` with nothing for this file to decide. The name is the same
461
+ // os.hostname() the heartbeat reports, which is how the two rows line up.
462
+ const fenceArgs = () => ({ query: { host: os.hostname() } });
463
+
458
464
  async function readFence(deps = {}) {
459
465
  const api = deps.api || await (require('./cli-lib').cliClient)();
460
466
  let raw = null;
461
467
  let graderBypassed = null;
462
468
  try {
463
- const r = await api.autonomy.getAutonomyFence();
469
+ const r = await api.autonomy.getAutonomyFence(fenceArgs());
464
470
  if (r && r.ok) raw = r.data;
465
471
  } catch (_) { /* unreadable → refuses below */ }
466
472
  try {
@@ -1011,7 +1017,9 @@ async function pollSignals(deps = {}) {
1011
1017
  if (r) head = r;
1012
1018
  } catch (_) { /* null */ }
1013
1019
  try {
1014
- const f = await api.autonomy.getAutonomyFence();
1020
+ // Named the same way, so a press of Run on this device moves the stamp the
1021
+ // wait is watching and the runner wakes inside a poll instead of an idle.
1022
+ const f = await api.autonomy.getAutonomyFence(fenceArgs());
1015
1023
  if (f && f.ok) fenceUpdatedAt = f.data.updated_at || null;
1016
1024
  } catch (_) { /* null */ }
1017
1025
  try {
@@ -1233,9 +1241,15 @@ async function forever(opts, deps = {}) {
1233
1241
  // `config_idle:<code>` instead of "waiting", so the hall shows the fix rather
1234
1242
  // than "alive" for ten hours. Any row that is not that refusal ends the stretch.
1235
1243
  let idle = null;
1244
+ // When a resting runner resumes, in epoch seconds (task 1005094): the reset the
1245
+ // last hold named. Sent on every beat that is not about a task, so the device's
1246
+ // Run button can show "Resting until 3:40" for the whole wait, not just for the
1247
+ // one beat that followed the hold. Any row that is not such a hold clears it.
1248
+ let restUntil = null;
1236
1249
  const clock = () => (deps.now ? deps.now() : Date.now());
1237
1250
  const beat = async (rawFields) => {
1238
- const fields = configIdle.markBeat(rawFields, idle, clock());
1251
+ const resting = restUntil && !(rawFields && rawFields.working_task_id) ? { ...rawFields, hold_until: restUntil } : rawFields;
1252
+ const fields = configIdle.markBeat(resting, idle, clock());
1239
1253
  // The runner state is passed, not defaulted: the file names `disk_head` from
1240
1254
  // it (task 1004406), and an injected state must reach the beat that reports it.
1241
1255
  try { beatFile(Date.now(), deps.state || RUNNER_STATE); } catch (_) { /* a runner that cannot write its file still runs */ }
@@ -1281,6 +1295,7 @@ async function forever(opts, deps = {}) {
1281
1295
  row = await iterate(opts, deps);
1282
1296
  emit(row);
1283
1297
  idle = configIdle.trackIdle(idle, row, clock());
1298
+ restUntil = row.event === 'hold' && Number(row.sleep_until) > 0 ? Number(row.sleep_until) : null;
1284
1299
  // Carry the task id so the hall can tell "quiet because it is building
1285
1300
  // task 1234" from "quiet because it is dead". A worker may hold the loop for
1286
1301
  // ninety minutes, and without this every honest hour of work looks like a
@@ -481,10 +481,29 @@ const STARTER_DENY_SECRETS = [
481
481
  'Read(**/*.pem)', 'Read(**/id_rsa*)', 'Read(**/id_ed25519*)',
482
482
  ];
483
483
  const SESSION_STORE_DENY = `Read(~/.config/${ic.FALLBACK_DIR}/instances/**)`;
484
+ // An Autobongos runner keeps ITS OWN login and files in a folder of its own (task 1005095:
485
+ // BONGOS_CONFIG_ROOT, so adding a laptop as a runner never touches the builder's CLI session).
486
+ // That put the runner's session file, and the session store beside it, outside every rule above,
487
+ // which all stop at ~/.config — so on a runner device a worker could open the file and read the
488
+ // runner's login (task 1005186). The whole folder is denied rather than one file name: the
489
+ // runner's heartbeat and run log sit there too and no agent has a reason to read any of it, and
490
+ // a rule on the folder cannot be stepped around by a session file under a new name.
491
+ // The folder name is the device program's (scripts/bongos/autobongos-host.js hostPaths); it is
492
+ // spelled here rather than required from there because this file is the installer and must not
493
+ // load the runner, and tests/claude_materialize.mjs fails if the two ever name different places.
494
+ const RUNNER_CONFIG_DENY = 'Read(~/.cloudbongos-runner/config/**)';
495
+
496
+ // The rules that name a FIXED place, whatever the instance is called.
497
+ const FIXED_PLACE_DENY = [SESSION_STORE_DENY, RUNNER_CONFIG_DENY];
484
498
 
485
499
  // A checkout's settings.json takes the identity rebrand like every copied file, and the store's
486
500
  // dir is one of the core's `.config/<dir>` literals it rewrites. The store does not move with the
487
501
  // brand (task 1003741), so its rule is put back where the CLI reads it (task 1004155).
502
+ //
503
+ // The runner rule needs no putting back: the rebrand rewrites `.config/<core dir>` literals, and
504
+ // the runner's folder is not one (measured: with the put-back removed, the three-brand case in
505
+ // tests/claude_materialize.mjs still holds). That case is what would notice a future rebrand
506
+ // rule broad enough to carry it off.
488
507
  function rebrandSettings(text, rebrand) {
489
508
  const moved = rebrand(SESSION_STORE_DENY);
490
509
  return rebrand(text).split(JSON.stringify(moved)).join(JSON.stringify(SESSION_STORE_DENY));
@@ -504,7 +523,7 @@ function credentialDeny(branding = {}) {
504
523
  return [
505
524
  ...STARTER_DENY_SECRETS,
506
525
  ...dirs.flatMap((dir) => credentialFiles.map((file) => `Read(~/.config/${dir}/${file})`)),
507
- SESSION_STORE_DENY,
526
+ ...FIXED_PLACE_DENY,
508
527
  ];
509
528
  }
510
529
 
@@ -30,6 +30,19 @@
30
30
  // MERGE commit's body, where GitHub echoes the branch's subject. A subject-only scan
31
31
  // misses the one case this file exists for.
32
32
  //
33
+ // A SECOND FORM, because the convention moved (task 1005187). Builders, and ship.js's
34
+ // own regeneration commits, now mostly write the task at the START of the subject:
35
+ // `task N - words`, `task N: words`, with an optional conventional prefix in front
36
+ // (`fix: task N - words`, `wip(scope): task N - words`). Measured over the last 400
37
+ // commits on main on 2026-11-10: 140 subjects in that form against 53 lines ending
38
+ // `(task N)`, covering 18 distinct tasks — under the floor of 20 the live-repo test
39
+ // holds, which is how main went red. So both forms count. The new one is read off the
40
+ // SUBJECT only, and only at its start, followed by a dash or a colon: the same
41
+ // "this commit DID task N" statement, and still not "mentions N" (a subject such as
42
+ // "see task N for why", or any body line naming a task, is not counted). A merge
43
+ // commit's subject is GitHub's, so for one of those the first line of its body — the
44
+ // pull request's own title, which GitHub echoes there — is read the same way.
45
+ //
33
46
  // MEASURED on this repo before shipping: 877 ids are named as an authoring subject
34
47
  // across 4,784 commits, and ZERO of them are open `ready`/`backlog` rows (434 of
35
48
  // those). So today's board is clean and the signal is not merely quiet — the same
@@ -66,13 +79,37 @@ const SEP = `${SOH}COMMIT${SOH}`;
66
79
  // mentioned N", and the second is the noise that made the supersession half undoable.
67
80
  const AUTHORED_RE = /\(task (\d{6,7})\)\s*$/;
68
81
 
82
+ // The second convention: the task leads the subject, after an optional conventional
83
+ // prefix, and is followed by a dash or a colon. Read off a TITLE line only (see
84
+ // titleLines) — never off every line, which is where "mentions N" lives.
85
+ const LEADING_RE = /^(?:[a-z]+(?:\([^)]*\))?!?:\s*)?task (\d{6,7})\s*[-:\u2013\u2014]/i;
86
+ const MERGE_SUBJECT_RE = /^Merge pull request #\d+ /;
87
+
88
+ // The lines of a message that are a TITLE: its subject, and for a GitHub merge commit
89
+ // the first line of the body too, where GitHub echoes the pull request's own title.
90
+ function titleLines(lines) {
91
+ const at = lines.findIndex((l) => l);
92
+ if (at === -1) return [];
93
+ const out = [lines[at]];
94
+ if (MERGE_SUBJECT_RE.test(lines[at])) {
95
+ const echoed = lines.slice(at + 1).find((l) => l);
96
+ if (echoed) out.push(echoed);
97
+ }
98
+ return out;
99
+ }
100
+
69
101
  // PURE — every task id a commit message claims to have DONE. Exported so the rule is
70
102
  // testable without a repo: a scanner that quietly stops matching reports a clean board,
71
103
  // which is indistinguishable from a healthy one.
72
104
  function authoredIds(message) {
73
105
  const out = new Set();
74
- for (const line of String(message || '').split('\n')) {
75
- const m = AUTHORED_RE.exec(line.trim());
106
+ const lines = String(message || '').split('\n').map((l) => l.trim());
107
+ for (const line of lines) {
108
+ const m = AUTHORED_RE.exec(line);
109
+ if (m) out.add(m[1]);
110
+ }
111
+ for (const line of titleLines(lines)) {
112
+ const m = LEADING_RE.exec(line);
76
113
  if (m) out.add(m[1]);
77
114
  }
78
115
  return out;
@@ -214,6 +214,13 @@ const CANNED_WRITES = [
214
214
  ['POST', /^ai-studio\/uploads$/, 'ai-studio__uploads.post'],
215
215
  ['POST', /^ai-studio\/requests$/, 'ai-studio__requests.post'],
216
216
  ['POST', /^ai-studio\/topup$/, 'ai-studio__topup.post'],
217
+ // Settings > Your Autobongos runner (task 1005094): a press of Run or Stop, a
218
+ // setup code for Add a device, and Remove (the login, then the card), so the
219
+ // Starting face, the setup command and the removal toast render here.
220
+ ['POST', /^autonomy\/runners\/[^/]+\/run$/, 'autonomy__runners__run.post'],
221
+ ['POST', /^auth\/runner-code\/issue$/, 'auth__runner-code__issue.post'],
222
+ ['POST', /^me\/sessions\/revoke$/, 'me__sessions__revoke.post'],
223
+ ['DELETE', /^autonomy\/runners\/[^/]+$/, 'autonomy__runners.delete'],
217
224
  ];
218
225
 
219
226
  // A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.77'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.79'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -221,6 +221,21 @@ test('the supervisor runs under the runner\'s own config root, and keeps a gauge
221
221
  assert.equal(host.supervisorEnv({ AUTOBONGOS_GAUGE_FILE: '/set/by/hand' }, paths).AUTOBONGOS_GAUGE_FILE, '/set/by/hand');
222
222
  });
223
223
 
224
+ test('AUTOBONGOS_HOME moves the runner\'s copy, logs and lock, and never the login', () => {
225
+ // The login must stay where the read-deny rule looks for it (task 1005186): a
226
+ // device someone customised is the last one that should fall outside the rule.
227
+ const home = path.join(tmp, 'fixed-login-home');
228
+ const standard = host.hostPaths({}, home);
229
+ const custom = host.hostPaths({ AUTOBONGOS_HOME: path.join(tmp, 'somewhere-else') }, home);
230
+ assert.equal(standard.configRoot, path.join(home, '.cloudbongos-runner', 'config'));
231
+ assert.equal(custom.configRoot, standard.configRoot, 'the same folder under the person\'s home, whatever the variable says');
232
+ assert.equal(custom.root, path.join(tmp, 'somewhere-else'));
233
+ assert.equal(custom.log, path.join(tmp, 'somewhere-else', 'logs', 'autobongos.log'));
234
+ assert.equal(custom.lock, path.join(tmp, 'somewhere-else', 'host.json'));
235
+ // And that is the folder the supervisor and its workers are pointed at.
236
+ assert.equal(host.supervisorEnv({ AUTOBONGOS_HOME: path.join(tmp, 'somewhere-else') }, custom).BONGOS_CONFIG_ROOT, standard.configRoot);
237
+ });
238
+
224
239
  test('under BONGOS_CONFIG_ROOT every session path moves, and the builder\'s own session is not a fallback', () => {
225
240
  const root = fresh('cfgroot');
226
241
  const probe = `
@@ -617,3 +632,34 @@ test('the PowerShell bootstrap stops with a sentence on an incomplete command, a
617
632
  // long dash turns into mojibake inside a string — or a parse error.
618
633
  assert.equal(/[^\x00-\x7F]/.test(readFileSync(PS1, 'utf8')), false);
619
634
  });
635
+
636
+ // ── the night, read from the runner's own files (task 1005097) ───────────────
637
+
638
+ test('the digest is read from the RUNNER’s run log: bare, the digest CLI looks in the builder’s own folder and finds none', () => {
639
+ const home = fresh('digest-home');
640
+ const paths = host.hostPaths({ AUTOBONGOS_HOME: home }, home);
641
+ const dirName = require('../src/instance-config').configDirName();
642
+ const logDir = path.join(paths.configRoot, dirName);
643
+ mkdirSync(logDir, { recursive: true });
644
+ writeFileSync(path.join(logDir, 'autobongos-runs.jsonl'), `${JSON.stringify({
645
+ at: new Date().toISOString(), event: 'worked', task_id: 424242, title: 'the task the runner built',
646
+ outcome: 'claims_shipped', verified: true, ledger_status: 'shipped', ledger_shape: 'shipped', cost_usd: 1.5,
647
+ })}\n`);
648
+
649
+ const argv = host.digestArgv(['--all', '--json']);
650
+ assert.equal(path.basename(argv[0]), 'autobongos-digest.js');
651
+ assert.deepEqual(argv.slice(1), ['--all', '--json'], 'whatever the person passed is handed through');
652
+
653
+ // Through the device program: the runner's night.
654
+ // The login folder is always under the person's home (task 1005186), so the
655
+ // child is given this test's stand-in home: HOME on a Mac or Linux, USERPROFILE
656
+ // on Windows. Otherwise it would read the real one.
657
+ const viaHost = spawnSync(process.execPath, [HOST_JS, 'digest', '--all', '--json'], { encoding: 'utf8', env: { ...process.env, HOME: home, USERPROFILE: home, AUTOBONGOS_HOME: home, AUTOBONGOS_LOG_FILE: '' } });
658
+ assert.equal(viaHost.status, 0, viaHost.stderr);
659
+ assert.ok(viaHost.stdout.includes('424242'), 'the task the runner built is in it');
660
+
661
+ // Bare, with an empty folder standing in for the builder's own: nothing to read.
662
+ const bare = spawnSync(process.execPath, argv, { encoding: 'utf8', env: { ...process.env, BONGOS_CONFIG_ROOT: fresh('digest-builder-root'), AUTOBONGOS_LOG_FILE: '' } });
663
+ assert.equal(bare.status, 0, 'the digest never fails a morning');
664
+ assert.equal(bare.stdout.includes('424242'), false, 'which is why the guide sends people through the device program');
665
+ });
@@ -223,10 +223,15 @@ test('the hall draws idle_config as a warning with the fix, on the Gate page and
223
223
  const gate = fs.readFileSync('modules/hall-ui/public/gate.js', 'utf8');
224
224
  assert.match(gate, /idle_config: 'warn'/, 'the dot is a warning, not ok');
225
225
  assert.match(gate, /h\.state === 'idle_config'[\s\S]{0,200}h\.remedy/, 'the checklist carries the remedy');
226
- const panel = fs.readFileSync('modules/hall-ui/public/settings-autobongos.js', 'utf8');
227
- assert.match(panel, /'GET', `\$\{API\}\/autonomy\/runners`/);
228
- assert.match(panel, /h\.state !== 'idle_config'/);
229
- assert.match(panel, /escapeHtml\(h\.remedy/);
226
+ // In Settings the device's own card says it (task 1005094): asked of the real
227
+ // button logic, with the verdict the server attaches to that runner.
228
+ const RB = require('../modules/hall-ui/public/runner-button.js');
229
+ const idle = describeRunner({ host: 'winbox', last_seen_at: new Date().toISOString(), last_event: 'config_idle:gauge_unknown', run_requested: true }, { nowMs: Date.now() });
230
+ assert.equal(idle.state, 'idle_config');
231
+ const open = { enabled: true, scope: { goals: [1] }, project: { enabled: true, goals: [{ goal_id: 1 }] } };
232
+ const b = RB.buttonState({ runner: { host: 'winbox', run_requested: true, last_seen_at: new Date().toISOString(), health: idle }, fence: open, nowMs: Date.now() });
233
+ assert.equal(b.tone, 'warn', 'a warning on the card, not ok');
234
+ assert.ok(b.sub.includes(idle.why) && b.sub.includes(idle.remedy), 'the card carries why it is idle and the fix');
230
235
  });
231
236
 
232
237
  // ── the route: POST /autonomy/heartbeat posts through the discord.post port ──