@bongos/core 1.24.77 → 1.24.78

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 (46) hide show
  1. package/.bongos-core.json +89 -44
  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 +2 -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 +14 -0
  35. package/scripts/bongos/autobongos-host.js +16 -2
  36. package/scripts/bongos/autobongos-run.js +18 -3
  37. package/scripts/bongos/stranded-rows.js +39 -2
  38. package/scripts/hall-preview/server.js +7 -0
  39. package/src/module-api.js +1 -1
  40. package/tests/autobongos_host.mjs +28 -0
  41. package/tests/autonomy_config_idle.mjs +9 -4
  42. package/tests/autonomy_runner_device.mjs +444 -0
  43. package/tests/autonomy_runner_identity.mjs +3 -1
  44. package/tests/hall_runner_button.mjs +467 -0
  45. package/tests/hall_runner_panel_connect.mjs +73 -0
  46. 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
@@ -755,6 +756,12 @@ async function waitForCheckIn({ session, fetchImpl = fetch, timeoutMs = 90000, s
755
756
  return false;
756
757
  }
757
758
 
759
+ // The digest CLI and its arguments, for `digest`: the same file a builder would
760
+ // run by hand, with whatever they passed handed through.
761
+ function digestArgv(rest = []) {
762
+ return [path.join(REPO_ROOT, 'scripts', 'bongos', 'autobongos-digest.js'), ...rest];
763
+ }
764
+
758
765
  // ── the commands ───────────────────────────────────────────────────────────
759
766
 
760
767
  async function runCommand({ paths = hostPaths() } = {}) {
@@ -819,6 +826,13 @@ async function main(argv) {
819
826
  console.log('Whether it is WORKING is on its card in Settings: the card reads its check-ins.');
820
827
  return 0;
821
828
  }
829
+ if (cmd === 'digest') {
830
+ // The runner's run log lives under ITS config root, so the digest has to be
831
+ // run with that root or it reads the builder's own (empty) one and reports a
832
+ // missing log (task 1005097).
833
+ const r = spawnSync(process.execPath, digestArgv(argv.slice(1)), { stdio: 'inherit', env: supervisorEnv(process.env, paths, dirName) });
834
+ return r.status === null ? 1 : r.status;
835
+ }
822
836
  if (cmd === 'install') {
823
837
  const e = install({ paths });
824
838
  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 +856,7 @@ async function main(argv) {
842
856
  for (const line of stopReport(result, { installed })) console.log(line);
843
857
  return result.outcome === 'still_running' || (cmd === 'uninstall' && installed) ? 1 : 0;
844
858
  }
845
- console.error('usage: autobongos-host.js setup --code <code> --api-base <url> | status [--json] | start | stop | install | uninstall | run');
859
+ console.error('usage: autobongos-host.js setup --code <code> --api-base <url> | status [--json] | start | stop | install | uninstall | digest [--since 24h] | run');
846
860
  return 2;
847
861
  }
848
862
 
@@ -865,5 +879,5 @@ module.exports = {
865
879
  pidAlive, lockHolder, wantsAwake, keepAwakeCommand, makeKeepAwake, runLoop,
866
880
  windowsRunArgv, windowsRunCommand, windowsStartSpawn, legacyBootTaskInstalled, launchdPlist,
867
881
  stopRunner, stopReport, exchangeCode, claudeState, waitForCheckIn,
868
- signIn, cleanBase, install, removeStartEntry, installedState, status,
882
+ signIn, cleanBase, install, removeStartEntry, installedState, status, digestArgv,
869
883
  };
@@ -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
@@ -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.78'; // 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');
@@ -617,3 +617,31 @@ test('the PowerShell bootstrap stops with a sentence on an incomplete command, a
617
617
  // long dash turns into mojibake inside a string — or a parse error.
618
618
  assert.equal(/[^\x00-\x7F]/.test(readFileSync(PS1, 'utf8')), false);
619
619
  });
620
+
621
+ // ── the night, read from the runner's own files (task 1005097) ───────────────
622
+
623
+ 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', () => {
624
+ const home = fresh('digest-home');
625
+ const paths = host.hostPaths({ AUTOBONGOS_HOME: home }, home);
626
+ const dirName = require('../src/instance-config').configDirName();
627
+ const logDir = path.join(paths.configRoot, dirName);
628
+ mkdirSync(logDir, { recursive: true });
629
+ writeFileSync(path.join(logDir, 'autobongos-runs.jsonl'), `${JSON.stringify({
630
+ at: new Date().toISOString(), event: 'worked', task_id: 424242, title: 'the task the runner built',
631
+ outcome: 'claims_shipped', verified: true, ledger_status: 'shipped', ledger_shape: 'shipped', cost_usd: 1.5,
632
+ })}\n`);
633
+
634
+ const argv = host.digestArgv(['--all', '--json']);
635
+ assert.equal(path.basename(argv[0]), 'autobongos-digest.js');
636
+ assert.deepEqual(argv.slice(1), ['--all', '--json'], 'whatever the person passed is handed through');
637
+
638
+ // Through the device program: the runner's night.
639
+ const viaHost = spawnSync(process.execPath, [HOST_JS, 'digest', '--all', '--json'], { encoding: 'utf8', env: { ...process.env, AUTOBONGOS_HOME: home, AUTOBONGOS_LOG_FILE: '' } });
640
+ assert.equal(viaHost.status, 0, viaHost.stderr);
641
+ assert.ok(viaHost.stdout.includes('424242'), 'the task the runner built is in it');
642
+
643
+ // Bare, with an empty folder standing in for the builder's own: nothing to read.
644
+ const bare = spawnSync(process.execPath, argv, { encoding: 'utf8', env: { ...process.env, BONGOS_CONFIG_ROOT: fresh('digest-builder-root'), AUTOBONGOS_LOG_FILE: '' } });
645
+ assert.equal(bare.status, 0, 'the digest never fails a morning');
646
+ assert.equal(bare.stdout.includes('424242'), false, 'which is why the guide sends people through the device program');
647
+ });
@@ -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 ──