@bongos/core 1.24.25 → 1.24.27

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 (114) hide show
  1. package/.bongos-core.json +173 -108
  2. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +12 -0
  3. package/docs/copy-inventory.md +280 -256
  4. package/docs/copy-registry.json +572 -306
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-inventory.json +10 -4
  7. package/docs/page-readings.json +4337 -4313
  8. package/docs/recipes/ui-look-before-you-ship.md +1 -0
  9. package/modules/autonomy/fence.js +16 -1
  10. package/modules/government/protected-surfaces.json +2 -0
  11. package/modules/hall-ui/public/ai-studio.html +2 -2
  12. package/modules/hall-ui/public/atlas.html +1 -1
  13. package/modules/hall-ui/public/blockers.html +2 -2
  14. package/modules/hall-ui/public/blockers.js +3 -1
  15. package/modules/hall-ui/public/board-room.html +3 -1
  16. package/modules/hall-ui/public/city.html +1 -1
  17. package/modules/hall-ui/public/collab.html +4 -4
  18. package/modules/hall-ui/public/collab.js +4 -0
  19. package/modules/hall-ui/public/commands.html +3 -3
  20. package/modules/hall-ui/public/copy-desk.html +3 -3
  21. package/modules/hall-ui/public/core-move-notice.js +9 -6
  22. package/modules/hall-ui/public/criterion-uat-ui.js +1 -1
  23. package/modules/hall-ui/public/deploy-handoff.js +203 -0
  24. package/modules/hall-ui/public/deploy.html +6 -2
  25. package/modules/hall-ui/public/deploy.js +7 -1
  26. package/modules/hall-ui/public/diagrams.html +1 -1
  27. package/modules/hall-ui/public/drachmae.html +2 -2
  28. package/modules/hall-ui/public/fleet.html +3 -1
  29. package/modules/hall-ui/public/gate.html +3 -1
  30. package/modules/hall-ui/public/goal-map.html +1 -1
  31. package/modules/hall-ui/public/goals-page.js +1 -1
  32. package/modules/hall-ui/public/goals.css +3 -2
  33. package/modules/hall-ui/public/goals.html +3 -3
  34. package/modules/hall-ui/public/goals.js +1 -1
  35. package/modules/hall-ui/public/government.html +3 -1
  36. package/modules/hall-ui/public/hall-widgets.js +9 -9
  37. package/modules/hall-ui/public/idea.html +1 -1
  38. package/modules/hall-ui/public/ideas.html +3 -3
  39. package/modules/hall-ui/public/index.html +3 -1
  40. package/modules/hall-ui/public/lightbulb.js +1 -1
  41. package/modules/hall-ui/public/market-watch-item.html +2 -2
  42. package/modules/hall-ui/public/market-watch-rules.html +2 -2
  43. package/modules/hall-ui/public/market-watch-settings.html +3 -1
  44. package/modules/hall-ui/public/market-watch.html +2 -2
  45. package/modules/hall-ui/public/modules.html +2 -2
  46. package/modules/hall-ui/public/my-agents.html +3 -3
  47. package/modules/hall-ui/public/primer.html +2 -2
  48. package/modules/hall-ui/public/profile.html +6 -6
  49. package/modules/hall-ui/public/project-settings.html +3 -1
  50. package/modules/hall-ui/public/ranks.html +4 -4
  51. package/modules/hall-ui/public/roadmap.html +2 -2
  52. package/modules/hall-ui/public/roster.html +2 -2
  53. package/modules/hall-ui/public/sessions.html +3 -1
  54. package/modules/hall-ui/public/settings-software-update.js +23 -9
  55. package/modules/hall-ui/public/settings-specialities.js +1 -1
  56. package/modules/hall-ui/public/settings.html +10 -6
  57. package/modules/hall-ui/public/settings.js +3 -0
  58. package/modules/hall-ui/public/shell.js +25 -73
  59. package/modules/hall-ui/public/street.html +1 -1
  60. package/modules/hall-ui/public/studio.html +1 -1
  61. package/modules/hall-ui/public/studio.js +3 -3
  62. package/modules/hall-ui/public/style.css +209 -40
  63. package/modules/hall-ui/public/task-detail.js +2 -2
  64. package/modules/hall-ui/public/task.html +1 -1
  65. package/modules/hall-ui/public/thinking.html +1 -1
  66. package/modules/hall-ui/public/tweak-editor.html +2 -2
  67. package/modules/hall-ui/public/update-pill.js +623 -0
  68. package/modules/hall-ui/public/watch.html +3 -1
  69. package/modules/hall-ui/public/watch.js +1 -1
  70. package/modules/hall-ui/public/work.html +4 -4
  71. package/modules/hall-ui/records/loader.md +35 -0
  72. package/modules/hall-ui/records/update-pill.md +12 -0
  73. package/modules/lifecycle/github-push.js +1 -0
  74. package/modules/lifecycle/routes/tasks.js +2 -2
  75. package/modules/lifecycle/runner-self-guard.js +122 -0
  76. package/modules/lifecycle/runner-self.js +154 -0
  77. package/modules/public-landing/public/account.html +3 -3
  78. package/modules/public-landing/public/assets/cosmos.css +45 -0
  79. package/modules/public-landing/public/community.html +4 -4
  80. package/modules/public-landing/public/connections.html +1 -1
  81. package/modules/public-landing/public/guild.html +1 -1
  82. package/modules/public-landing/public/invitations.html +4 -4
  83. package/modules/public-landing/public/projects.html +26 -26
  84. package/modules/status-ui/public/index.html +2 -2
  85. package/modules/status-ui/public/index.states.json +12 -0
  86. package/modules/status-ui/public/status.js +10 -2
  87. package/modules/status-ui/public/style.css +57 -0
  88. package/modules/ui-design/kit/README.md +1 -1
  89. package/modules/ui-design/kit/serve.js +8 -1
  90. package/package-lock.json +2 -2
  91. package/package.json +1 -1
  92. package/release-notes.json +24 -0
  93. package/scripts/bongos/autobongos-loop.js +4 -0
  94. package/scripts/bongos/autobongos-run.js +21 -4
  95. package/scripts/bongos/autobongos-tweak-lane.js +5 -3
  96. package/scripts/bongos/ship-flow.js +20 -0
  97. package/scripts/hall-preview/README.md +1 -0
  98. package/scripts/hall-preview/server.js +9 -0
  99. package/src/bongos/apex-hall-redirects.js +53 -0
  100. package/src/bongos/serve-internal.js +3 -18
  101. package/src/module-api.js +1 -1
  102. package/tests/autobongos_host_access.mjs +5 -3
  103. package/tests/autobongos_self_modification.mjs +420 -0
  104. package/tests/core_update_banner.mjs +6 -92
  105. package/tests/core_upgrade_owner_door.mjs +4 -56
  106. package/tests/deploy_handoff.mjs +206 -0
  107. package/tests/government_protected_surfaces.mjs +15 -0
  108. package/tests/hall_loading.mjs +153 -0
  109. package/tests/hub_connections_page.mjs +1 -1
  110. package/tests/hub_invitations_page.mjs +1 -1
  111. package/tests/loading_mark.mjs +203 -0
  112. package/tests/nav_permission_atoms.mjs +3 -2
  113. package/tests/software_update.mjs +39 -2
  114. package/tests/update_pill.mjs +431 -0
@@ -22,6 +22,7 @@ node scripts/hall-preview/server.js # neutral pack, port 4600
22
22
  | `--modules-off discord,provisioning` | report those modules disabled in `window.__MODULES__` — verifies `moduleOn()` gating (the Discord links, Instances/Start-a-project widgets must vanish) |
23
23
  | `--empty` | drain every array in every fixture — the empty-state screenshot pass |
24
24
  | `--fixture-me` | don't read your own account live — serve the checked-in `me.json` snapshot (see below) |
25
+ | `--latency 3000` | hold every data read that many ms before answering, so a page's loading state (the hall's `.hall-loading` mark) stays on screen long enough to look at |
25
26
 
26
27
  Writes are not wired: any `POST/PATCH/DELETE` answers `501 preview_readonly` — except
27
28
  the canned writes in `server.js` and, since task 1004685, the five per-builder pref
@@ -65,6 +65,10 @@ const MODULES_ON = String(arg('--modules-on') || '').split(',').map((s) => s.tri
65
65
  // own account live. The screenshot matrix in the README wants a stable subject,
66
66
  // and it is the explicit way to preview the signed-out fallback (task 1003315).
67
67
  const FIXTURE_ME = process.argv.includes('--fixture-me');
68
+ // --latency: hold every data read this many ms before answering (task 1005043),
69
+ // so a page's loading state stays on screen long enough to be looked at. The
70
+ // fixtures answer in a millisecond, which is why no loader had ever been seen.
71
+ const LATENCY = Math.max(0, Number(arg('--latency')) || 0);
68
72
  const BRAND_FILE = arg('--brand') || 'config/branding.neutral.json';
69
73
  process.env.BONGOS_BRANDING_FILE = path.resolve(ROOT, BRAND_FILE);
70
74
 
@@ -295,6 +299,11 @@ function fixtureFor(apiPath, query = {}) {
295
299
  // ---- app ----------------------------------------------------------------------
296
300
  const app = express();
297
301
 
302
+ // --latency (above): the live channel is left alone, it is a stream, not a read.
303
+ if (LATENCY) {
304
+ app.use((req, res, next) => (/^\/api\//.test(req.path) && !/\/live\/?$/.test(req.path) ? setTimeout(next, LATENCY) : next()));
305
+ }
306
+
298
307
  // The API stub — intercepts every /api/gds/*, /api/bongos/*, /api/bongos/v1/*
299
308
  // request BEFORE mountInternalSurfaces can reach the real routes (which would
300
309
  // touch Postgres — the harness is DB-less by design). A plain function
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/apex-hall-redirects.js — the apex hands the hall's addresses to the hall's own
4
+ // host: /builders/* (the ADR 0036 cutover) and /deploy, the deploy door every hosted
5
+ // project's Update button names (task 1004736).
6
+ //
7
+ // Moved out of serve-internal.js, which sits at the 1500-line size ratchet, and mounted from
8
+ // it at the same point in the chain. `skip(req)` is true for a module surface host and for
9
+ // the hall's own host (on a single-host instance that is every request), so both answer only
10
+ // on the apex. `hostOf(req)` is the request's host, lower-cased, without a port.
11
+
12
+ // #739 (ADR 0036 cutover): the builder surface's canonical home is the subdomain. On the
13
+ // apex, /builders and /builders/* go to builders.<apex>/<rest> so old links keep working.
14
+ // 301 (permanent) — the cutover is proven live (#741; started as a 302 in #739). The URL
15
+ // fragment (#/task/N deep-links) is preserved by the browser across the redirect; the query
16
+ // string is carried explicitly. The target apex comes from the request host, so it stays
17
+ // brand-agnostic.
18
+ function buildersRedirect({ skip, hostOf }) {
19
+ return (req, res, next) => {
20
+ if (skip(req)) return next();
21
+ if (req.path !== '/builders' && !req.path.startsWith('/builders/')) return next();
22
+ const rest = req.path.replace(/^\/builders\/?/, ''); // '/builders'→'', '/builders/watch'→'watch'
23
+ const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
24
+ return res.redirect(301, `https://builders.${hostOf(req)}/${rest}${qs}`);
25
+ };
26
+ }
27
+
28
+ // THE DEPLOY DOOR ON THE APEX (task 1004736). A hosted project's Update button is
29
+ // `<hub origin>/deploy?project=<slug>` (routes/core-update.js hubDeployUrl, and the
30
+ // HUB_UPGRADE_URL provisioning writes), and the hub origin is its apex, where nothing served
31
+ // /deploy: every hosted owner who pressed Update met a 404. The page is the hall's, so the
32
+ // apex hands the request over, query and all, and the hall decides (its page gate, or
33
+ // Settings where no door runs). Answered here rather than by rewriting the links, because
34
+ // those already sit in every project's web.env and core and would wait for each project's
35
+ // core to move. 302: the apex may serve its own /deploy one day.
36
+ const APEX_DEPLOY_RE = /^\/deploy(?:\.html)?\/?$/;
37
+ function deployRedirect({ skip, hostOf }) {
38
+ return (req, res, next) => {
39
+ if (skip(req)) return next();
40
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
41
+ if (!APEX_DEPLOY_RE.test(req.path)) return next();
42
+ const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
43
+ return res.redirect(302, `https://builders.${hostOf(req)}/deploy${qs}`);
44
+ };
45
+ }
46
+
47
+ /** Mount both on `app`, in order, at the caller's point in the chain. */
48
+ function mountApexHallRedirects(app, { skip, hostOf }) {
49
+ app.use(buildersRedirect({ skip, hostOf }));
50
+ app.use(deployRedirect({ skip, hostOf }));
51
+ }
52
+
53
+ module.exports = { mountApexHallRedirects };
@@ -34,6 +34,7 @@ const fs = require('fs');
34
34
  const express = require('express');
35
35
 
36
36
  const { buildGdsRouter } = require('./routes');
37
+ const { mountApexHallRedirects } = require('./apex-hall-redirects');
37
38
  const { attachFail } = require('./middleware/error-envelope');
38
39
  const metrics = require('./metrics');
39
40
  const { logger } = require('./logger');
@@ -923,24 +924,8 @@ function mountInternalSurfaces(app) {
923
924
  next();
924
925
  });
925
926
 
926
- // #739 (ADR 0036 cutover): the builder surface's canonical home is now the
927
- // subdomain. On the apex/game host, redirect /builders and /builders/* to
928
- // builders.<apex>/<rest> so old links keep working while the subdomain becomes
929
- // the front door. Runs ONLY on the apex (skipped on the status + builders
930
- // hosts; /api is host-agnostic and mounted earlier, and never starts /builders).
931
- // 301 (permanent) — the cutover is proven live + verified end-to-end (#741;
932
- // started as a 302 in #739). The URL fragment (#/task/N deep-links) is preserved
933
- // by the browser across the redirect; the query string is carried explicitly.
934
- // The redirect target apex is read from the request host (strip a leading
935
- // "builders."/"status." if present) so it stays brand-agnostic.
936
- app.use((req, res, next) => {
937
- if (isModuleSurfaceHost(req) || isBuildersHost(req)) return next();
938
- if (req.path !== '/builders' && !req.path.startsWith('/builders/')) return next();
939
- const apex = siteOf(req).host;
940
- const rest = req.path.replace(/^\/builders\/?/, ''); // '/builders'→'', '/builders/watch'→'watch'
941
- const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
942
- return res.redirect(301, `https://builders.${apex}/${rest}${qs}`);
943
- });
927
+ // The apex hands /builders/* (ADR 0036) and /deploy (task 1004736) to the hall's own host.
928
+ mountApexHallRedirects(app, { skip: (req) => isModuleSurfaceHost(req) || isBuildersHost(req), hostOf: (req) => siteOf(req).host });
944
929
 
945
930
  // The /watch monitoring page and the /gate page (task 1179) both sit behind
946
931
  // `page.view.government` — Metic and up since ADR 0157. Same server-side page
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.25'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.27'; // 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');
@@ -48,9 +48,11 @@ const T1003980 = {
48
48
  };
49
49
  const CODE_TASK = {
50
50
  id: 9002, goal_id: 1000119, kind: 'feature', status: 'ready', requires_rank: 'xenos',
51
- touches: ['scripts/bongos/sequence.js'], manual_degree: null, automation_tag: null,
52
- title: 'Teach the picker a new halt reason',
53
- description: 'Add a halt reason to scripts/bongos/sequence.js and cover it with a tests/*.mjs case. '.repeat(4)
51
+ // Not the picker itself: since task 1004546 sequence.js is on the runner's own
52
+ // list, so a task editing it is passed over as self_modification.
53
+ touches: ['modules/hall-ui/public/goals.js'], manual_degree: null, automation_tag: null,
54
+ title: 'Show a new column on the goals page',
55
+ description: 'Add a column to modules/hall-ui/public/goals.js and cover it with a tests/*.mjs case. '.repeat(4)
54
56
  + 'DONE WHEN: the new case passes and the old ones still do.',
55
57
  };
56
58
 
@@ -0,0 +1,420 @@
1
+ // tests/autobongos_self_modification.mjs — an Autobongos runner never changes its
2
+ // own supervisor, safety checks or kill switch (task 1004546, carrying task
3
+ // 1004540's work; ADR 0307 §6).
4
+ //
5
+ // The done-when, case by case:
6
+ // 1. a task DECLARING modules/autonomy/fence.js is refused before the claim
7
+ // (the picker skips it, and decideTask refuses it if it is handed one);
8
+ // 2. an UNDECLARED edit to an autobongos-* file in the real diff is caught, by
9
+ // ship.js inside the worker and by the server at publish, which reads the
10
+ // pull request's own files and closes it;
11
+ // plus the three findings that failed task 1004540's second grade:
12
+ // - the guard's own implementation is on the list it enforces, and the server
13
+ // guard requires its matcher directly rather than through the module doorway;
14
+ // - the wiring in the publish route is held by driving the REAL route here, so
15
+ // deleting that line turns this file red (and this file is on the list);
16
+ // - the runner-worktree shape is written once.
17
+ // No DB, no network: fake pools, the real router, stubbed GitHub reads.
18
+ //
19
+ // Run: node tests/autobongos_self_modification.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { createRequire } from 'node:module';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import http from 'node:http';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { makeRunner } from './helpers.mjs';
28
+
29
+ process.env.NODE_ENV = 'test';
30
+ const require = createRequire(import.meta.url);
31
+ const ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
32
+ const { test, summary } = makeRunner();
33
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
34
+
35
+ const runnerSelf = require('../modules/lifecycle/runner-self.js');
36
+ const guard = require('../modules/lifecycle/runner-self-guard.js');
37
+ const fence = require('../modules/autonomy/fence.js');
38
+ const runner = require('../scripts/bongos/autobongos-run.js');
39
+ const pcheck = require('../src/bongos/permission-path-check.js');
40
+
41
+ const onList = (f) => runnerSelf.matchRunnerSelf([f]).length === 1;
42
+
43
+ // ---- the list ---------------------------------------------------------------
44
+
45
+ await test('every file that implements the self-edit checks is on the list it enforces', () => {
46
+ const implementation = [
47
+ 'modules/lifecycle/runner-self.js', // the list
48
+ 'modules/lifecycle/runner-self-guard.js', // the server refusal
49
+ 'modules/autonomy/fence.js', // decideTask
50
+ 'scripts/bongos/autobongos-run.js', // the picker skip + the worker env
51
+ 'scripts/bongos/ship-flow.js', // the ship-time check
52
+ 'scripts/bongos/ship.js',
53
+ 'tests/autobongos_self_modification.mjs', // this file, which holds the route wiring
54
+ ];
55
+ for (const f of implementation) assert.ok(onList(f), `${f} must be on the runner's own list`);
56
+ });
57
+
58
+ await test('everything the server guard requires is on the list too, and the runner-list matcher is never the doorway\'s', () => {
59
+ const src = read('modules/lifecycle/runner-self-guard.js');
60
+ const required = [...src.matchAll(/require\('(\.\/[^']+)'\)/g)].map((m) => m[1]);
61
+ assert.ok(required.includes('./runner-self'), 'the runner-list matcher is required directly, as a sibling');
62
+ for (const rel of required) {
63
+ const file = `modules/lifecycle/${rel.replace(/^\.\//, '')}.js`;
64
+ assert.ok(onList(file), `${file} decides what the guard sees, so a runner may not change it`);
65
+ }
66
+ // The registry matcher can only come through the doorway; it is verified before use.
67
+ assert.match(src, /verifiedMatcher\(deps\.matchProtected \|\| \(\(files\) => api\.permissionPathCheck\.matchProtected\(files\)\)\)/);
68
+ assert.doesNotMatch(src, /api\.pathMatch/);
69
+ // And the list itself requires nothing at all.
70
+ assert.doesNotMatch(read('modules/lifecycle/runner-self.js'), /require\(/);
71
+ });
72
+
73
+ await test('the route line that calls the server guard is itself a protected file', () => {
74
+ // The owner kept tasks.js off the runner's own list; the registry covers it, and
75
+ // the real-diff checks read the registry, so a runner cannot publish its removal.
76
+ assert.equal(pcheck.matchProtected(['modules/lifecycle/routes/tasks.js']).length, 1);
77
+ assert.equal(runnerSelf.diffRefusal({ files: ['modules/lifecycle/routes/tasks.js'], protectedHits: pcheck.matchProtected(['modules/lifecycle/routes/tasks.js']) }).code, 'runner_protected_path');
78
+ });
79
+
80
+ await test('the kill switch, the gauge, the grader and the registry are on the list', () => {
81
+ for (const f of [
82
+ 'modules/autonomy/routes/autonomy.js', 'modules/autonomy/gauge.js', 'modules/autonomy/cadence.js',
83
+ 'modules/government/catalog.js', 'modules/government/protected-surfaces.json', 'src/bongos/permission-path-check.js',
84
+ 'modules/grading/grader.js', 'scripts/bongos/autobongos-loop.js', 'scripts/bongos/autobongos-verify.js',
85
+ 'scripts/bongos/autobongos-grade-cap.js', 'scripts/gds/autobongos-run.js', 'scripts/bongos/autonomy-gauge.js',
86
+ 'scripts/bongos/sequence.js', 'modules/lifecycle/autopickup-files.js',
87
+ ]) assert.ok(onList(f), f);
88
+ });
89
+
90
+ await test('the module doorway is on the list: the server guard reaches its matcher and pool through it', () => {
91
+ // Owner ruling 2026-11-03 (grade round 2 of this task): a runner that could rewrite
92
+ // the doorway could swap what the server guard checks with.
93
+ assert.ok(onList('src/module-api.js'));
94
+ assert.match(read('modules/lifecycle/runner-self-guard.js'), /require\('\.\.\/\.\.\/src\/module-api'\)/, 'the guard does reach it, so it must stay listed');
95
+ });
96
+
97
+ await test('the tweak lane asks the runner-list question too', () => {
98
+ const lane = require('../scripts/bongos/autobongos-tweak-lane.js');
99
+ const d = lane.decideRound({ task: { id: 7, goal_id: 1, updated_at: 'x' }, claims: [], files: ['modules/autonomy/fence.js'], matchProtected: () => [] });
100
+ assert.equal(d.code, 'self_modification');
101
+ });
102
+
103
+ await test('ordinary work is not on the list (the owner ruled tasks.js out)', () => {
104
+ for (const f of [
105
+ 'modules/lifecycle/routes/tasks.js', 'modules/hall-ui/public/settings.js',
106
+ 'scripts/bongos/claim.js', 'docs/recipes/autobongos-windows-host.md', 'modules/lifecycle/db-claims.js',
107
+ 'scripts/bongos/autobongos-lib/nested.js', // a '*' pattern stays inside its directory
108
+ ]) assert.ok(!onList(f), f);
109
+ });
110
+
111
+ await test('every entry on the list says why it is there, and none is listed twice', () => {
112
+ const patterns = runnerSelf.RUNNER_SELF_SURFACES.map((s) => s.pattern);
113
+ assert.equal(new Set(patterns).size, patterns.length);
114
+ for (const s of runnerSelf.RUNNER_SELF_SURFACES) {
115
+ assert.ok(typeof s.why === 'string' && s.why.length > 5, `${s.pattern} carries a reason`);
116
+ assert.match(s.pattern, /^[a-z0-9_./-]+(\/|\*)?$/, `${s.pattern} is an exact path, a 'dir/' or a 'prefix*'`);
117
+ }
118
+ assert.ok(Object.isFrozen(runnerSelf.RUNNER_SELF_SURFACES), 'nothing at run time can shorten it');
119
+ });
120
+
121
+ await test('a declared touch cannot slip past on its spelling', () => {
122
+ for (const f of ['scripts\\bongos\\autobongos-run.js', './modules/autonomy/fence.js', 'Modules/Autonomy/Fence.js', '/modules/autonomy/fence.js']) {
123
+ assert.ok(onList(f), f);
124
+ }
125
+ assert.deepEqual(runnerSelf.matchRunnerSelf(['', null, 42]), []);
126
+ });
127
+
128
+ await test('both guard files are on the ADR 0043 registry, at the pipeline floor', () => {
129
+ const hits = pcheck.matchProtected(['modules/lifecycle/runner-self.js', 'modules/lifecycle/runner-self-guard.js']);
130
+ assert.equal(hits.length, 2);
131
+ for (const h of hits) assert.equal(h.permission, 'path.protected.modify');
132
+ });
133
+
134
+ // ---- one shape for a runner's claim -----------------------------------------
135
+
136
+ await test('the runner-worktree shape is written once, and the runner uses that one', () => {
137
+ assert.equal(runner.RUNNER_TREE_RE, runnerSelf.RUNNER_TREE_RE, 'the supervisor re-exports the shared regex');
138
+ assert.equal(runner.isRunnerTree, runnerSelf.isRunnerTree);
139
+ for (const id of [1, 4242, 1004546]) assert.ok(runnerSelf.isRunnerTree(runner.worktreeName(id)), `worktreeName(${id})`);
140
+ assert.equal(runnerSelf.isRunnerTree('autobongos-resume-1003904-d29f25'), false, "a person's readable name is not the runner's");
141
+ // No other file spells the shape: a second copy is how two checks drift apart.
142
+ const spelling = String.raw`autobongos-\d+-[0-9a-f]{6}`;
143
+ const copies = [];
144
+ for (const dir of ['modules', 'scripts', 'src']) {
145
+ const walk = (d) => {
146
+ for (const e of fs.readdirSync(path.join(ROOT, d), { withFileTypes: true })) {
147
+ const rel = `${d}/${e.name}`;
148
+ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(rel); continue; }
149
+ if (/\.js$/.test(e.name) && read(rel).includes(spelling)) copies.push(rel);
150
+ }
151
+ };
152
+ walk(dir);
153
+ }
154
+ assert.deepEqual(copies, ['modules/lifecycle/runner-self.js']);
155
+ });
156
+
157
+ // ---- 1. declared: refused before the claim ----------------------------------
158
+
159
+ await test('decideTask refuses a task declaring modules/autonomy/fence.js, and names it', () => {
160
+ const task = { id: 7, goal_id: 1000118, touches: ['modules/autonomy/fence.js'] };
161
+ const d = fence.decideTask({ task, goals: [1000118], protectedHits: [], selfHits: runnerSelf.matchRunnerSelf(task.touches) });
162
+ assert.equal(d.go, false);
163
+ assert.equal(d.code, 'self_modification');
164
+ assert.deepEqual(d.files, ['modules/autonomy/fence.js']);
165
+ assert.match(d.reason, /supervisor, safety checks or kill switch/);
166
+ });
167
+
168
+ await test('decideTask keeps its order: a protected path is still reported as one', () => {
169
+ const task = { id: 7, goal_id: 1, touches: ['scripts/gds/autobongos-run.js'] };
170
+ const d = fence.decideTask({ task, goals: [1], protectedHits: pcheck.matchProtected(task.touches), selfHits: runnerSelf.matchRunnerSelf(task.touches) });
171
+ assert.equal(d.code, 'protected_path');
172
+ assert.equal(fence.decideTask({ task: { id: 8, goal_id: 1, touches: [] }, goals: [1] }).go, true, 'no hits, no refusal');
173
+ });
174
+
175
+ function fakeApi(tasks) {
176
+ return {
177
+ me: { getMe: async () => ({ ok: true, data: { builder: { rank: 'archon' } } }) },
178
+ tasks: {
179
+ getTasks: async ({ query }) => ({ ok: true, data: { tasks: query.offset ? [] : tasks.map((t) => ({ goal_id: query.goal_id, ...t })) } }),
180
+ getTasksId: async ({ id }) => {
181
+ const t = tasks.find((x) => Number(x.id) === Number(id));
182
+ return t ? { ok: true, data: { task: { goal_id: 1000118, ...t } } } : { ok: false };
183
+ },
184
+ getTasksClaimable: async () => ({ ok: true, data: { claimable: tasks.map((t) => ({ id: t.id })) } }),
185
+ },
186
+ };
187
+ }
188
+ const ready = (over) => ({ title: 'A change', status: 'ready', kind: 'bug', description: 'x'.repeat(400), dependencies: [], touches: [], requires_rank: 'xenos', ...over });
189
+
190
+ await test('the picker passes over a self-editing task and takes the next one, listing why', async () => {
191
+ const api = fakeApi([ready({ id: 9001, touches: ['modules/autonomy/fence.js'] }), ready({ id: 9002 })]);
192
+ const got = await runner.pickTask([1000118], { api, rank: 'archon' });
193
+ assert.equal(got.task.id, 9002, 'the runner must not keep re-picking work it may never do');
194
+ const sk = got.skipped.find((s) => s.id === 9001);
195
+ assert.ok(sk, 'the passed-over task is listed');
196
+ assert.equal(sk.reasons[0].code, 'self_modification');
197
+ });
198
+
199
+ await test('a goal of nothing but self-editing work reads as an unworkable queue, not idle', async () => {
200
+ const got = await runner.pickTask([1000118], { api: fakeApi([ready({ id: 9001, touches: ['scripts/bongos/autobongos-loop.js'] })]), rank: 'archon' });
201
+ assert.equal(got.none, true);
202
+ assert.deepEqual(got.skipped.map((s) => s.reasons[0].code), ['self_modification']);
203
+ });
204
+
205
+ await test('an iteration handed a self-editing task makes no tree, no claim and no worker', async () => {
206
+ const calls = [];
207
+ const rows = [];
208
+ const deps = {
209
+ gauge: () => ({ decision: { go: true, unknown: false, sleepUntil: null, reason: 'headroom' } }),
210
+ readFence: async () => ({ raw: { enabled: true, paused_reason: null, goals: [{ goal_id: 1000118 }] }, graderBypassed: false }),
211
+ api: {},
212
+ state: runner.newRunnerState(),
213
+ pickTask: async () => ({ task: { id: 4242, title: 't', kind: 'bug', description: 'd'.repeat(300), goal_id: 1000118, touches: ['modules/autonomy/fence.js'] }, goalId: 1000118, skipped: [] }),
214
+ worktreeName: () => 'autobongos-4242-abc123',
215
+ record: (r) => { rows.push(r); return r; },
216
+ run: async (bin, args) => { calls.push(args[0]); return { ok: true, code: 0, stdout: '', stderr: '' }; },
217
+ spawnWorker: async () => { calls.push('WORKER'); return { envelope: {}, exitCode: 0 }; },
218
+ };
219
+ const row = await runner.iteration({ goals: [1000118], maxTasks: 1, dryRun: false, model: 'm', timeoutMs: 1000 }, deps);
220
+ assert.equal(row.event, 'fenced');
221
+ assert.equal(row.code, 'self_modification');
222
+ assert.deepEqual(row.files, ['modules/autonomy/fence.js'], 'the log row names the file');
223
+ assert.deepEqual(calls, []);
224
+ });
225
+
226
+ // ---- 2. undeclared: caught in the real diff ---------------------------------
227
+
228
+ await test('every worker the supervisor spawns carries the runner flag', () => {
229
+ const env = runner.workerEnv({ PATH: '/usr/bin' });
230
+ assert.equal(env[runnerSelf.RUNNER_WORKER_ENV], '1');
231
+ assert.equal(runnerSelf.RUNNER_WORKER_ENV, 'AUTOBONGOS_WORKER');
232
+ });
233
+
234
+ await test('ship.js inside a worker refuses an undeclared autobongos-* edit; outside one it does nothing', () => {
235
+ const files = ['modules/hall-ui/public/x.js', 'scripts/bongos/autobongos-loop.js'];
236
+ const worker = { AUTOBONGOS_WORKER: '1' };
237
+ assert.deepEqual(runnerSelf.selfEditRefusal({ files, env: worker, matchProtected: pcheck.matchProtected }), { code: 'self_modification', files: ['scripts/bongos/autobongos-loop.js'], protected: [] });
238
+ assert.equal(runnerSelf.selfEditRefusal({ files, env: {}, matchProtected: pcheck.matchProtected }), null, 'a person shipping is never refused');
239
+ assert.equal(runnerSelf.selfEditRefusal({ files: ['modules/hall-ui/public/x.js'], env: worker, matchProtected: pcheck.matchProtected }), null);
240
+ });
241
+
242
+ await test('ship.js inside a worker refuses an undeclared protected file, and only asks the matcher inside a worker', () => {
243
+ const worker = { AUTOBONGOS_WORKER: '1' };
244
+ const r = runnerSelf.selfEditRefusal({ files: ['migrations/999_x.sql', 'docs/a.md'], env: worker, matchProtected: pcheck.matchProtected });
245
+ assert.deepEqual(r, { code: 'runner_protected_path', files: [], protected: ['migrations/999_x.sql'] });
246
+ assert.match(runnerSelf.selfEditMessage(r), /protected permission, migration, infrastructure or pipeline file \(migrations\/999_x\.sql\)/);
247
+ let asked = 0;
248
+ runnerSelf.selfEditRefusal({ files: ['migrations/999_x.sql'], env: {}, matchProtected: () => { asked++; return []; } });
249
+ assert.equal(asked, 0, 'a person\'s ship never runs the check at all');
250
+ });
251
+
252
+ await test('ship.js runs that check on the committed diff, before the grade, on both the ship and the re-grade', () => {
253
+ const src = read('scripts/bongos/ship-flow.js');
254
+ const sites = src.match(/const committedFiles = computeChangedFiles\(graderBaseline\);\n\s*refuseRunnerSelfEdit\(committedFiles\);/g) || [];
255
+ assert.equal(sites.length, 2, 'shipMain and regradeMain each check before anything is resolved or graded');
256
+ assert.match(src, /function refuseRunnerSelfEdit\(files\) \{\n\s*const refusal = runnerSelf\.selfEditRefusal\(\{ files, matchProtected \}\);/);
257
+ });
258
+
259
+ await test('the worker is told not to add a session log, which would make the ship touch the protected CLAUDE.md', () => {
260
+ const { buildWorkerPrompt } = require('../scripts/bongos/autobongos-loop.js');
261
+ assert.match(buildWorkerPrompt({ task: { id: 7, title: 't', description: 'd' } }), /Do not add a session-log file under docs\/session-logs\//);
262
+ assert.equal(pcheck.matchProtected(['CLAUDE.md']).length, 1, 'still protected, so the instruction is load-bearing');
263
+ });
264
+
265
+ const RUNNER_CLAIM = { worktree_name: 'autobongos-4242-abc123', requested_worktree_name: 'autobongos-4242-abc123' };
266
+ const files = (...p) => p.map((x) => ({ path: x }));
267
+ const refusal = (claim, prFiles, extra = {}) => guard.runnerSelfEditRefusal({ taskId: 4242, prNumber: 77 }, {
268
+ readLatestClaim: async () => claim, getPullRequestFiles: async () => prFiles, ...extra,
269
+ });
270
+
271
+ await test('the server refuses a runner\'s pull request that edits an autobongos-* file it never declared', async () => {
272
+ const r = await refusal(RUNNER_CLAIM, files('modules/hall-ui/public/x.js', 'scripts/bongos/autobongos-loop.js'));
273
+ assert.deepEqual(r, { code: 'self_modification', files: ['scripts/bongos/autobongos-loop.js'], protected: [] });
274
+ assert.match(guard.refusalMessage(r), /autobongos-loop\.js.*pull request was closed/s);
275
+ });
276
+
277
+ await test('the server refuses a runner\'s pull request that touches a protected file, the route itself included', async () => {
278
+ const r = await refusal(RUNNER_CLAIM, files('modules/lifecycle/routes/tasks.js'));
279
+ assert.equal(r.code, 'runner_protected_path');
280
+ assert.deepEqual(r.protected, ['modules/lifecycle/routes/tasks.js']);
281
+ assert.match(guard.refusalMessage(r), /protected permission, migration, infrastructure or pipeline file/);
282
+ });
283
+
284
+ await test('a registry matcher that does not flag the paths it always has is not trusted', async () => {
285
+ for (const matchProtected of [() => [], () => { throw new Error('swapped'); }, () => null]) {
286
+ const r = await refusal(RUNNER_CLAIM, files('docs/a.md'), { matchProtected });
287
+ assert.equal(r.code, 'self_modification_unreadable', 'a neutered matcher refuses rather than waving everything through');
288
+ }
289
+ });
290
+
291
+ await test('moving a guard away is an edit to it', async () => {
292
+ const r = await refusal(RUNNER_CLAIM, [{ path: 'modules/elsewhere/fence.js', previous_path: 'modules/autonomy/fence.js', status: 'renamed' }]);
293
+ assert.equal(r.code, 'self_modification');
294
+ assert.deepEqual(r.files, ['modules/autonomy/fence.js']);
295
+ });
296
+
297
+ await test('a person\'s claim is never checked, and its files are never read', async () => {
298
+ let read = 0;
299
+ const r = await refusal({ worktree_name: 'my-branch', requested_worktree_name: null }, files('modules/autonomy/fence.js'), {
300
+ getPullRequestFiles: async () => { read++; return []; },
301
+ });
302
+ assert.equal(r, null);
303
+ assert.equal(read, 0);
304
+ assert.equal(await refusal(null, files('modules/autonomy/fence.js')), null, 'no claim row, no check');
305
+ });
306
+
307
+ await test('a disambiguated name is still the runner when the requested one is', async () => {
308
+ const r = await refusal({ worktree_name: 'autobongos-4242-abc123-2', requested_worktree_name: 'autobongos-4242-abc123' }, files('modules/autonomy/gauge.js'));
309
+ assert.equal(r.code, 'self_modification');
310
+ });
311
+
312
+ await test('a runner\'s files that cannot be read in full are refused, never waved through', async () => {
313
+ assert.equal((await refusal(RUNNER_CLAIM, null, { getPullRequestFiles: async () => { throw new Error('502'); } })).code, 'self_modification_unreadable');
314
+ const cap = require('../modules/lifecycle/ship-files.js').PR_FILES_CAP;
315
+ const many = Array.from({ length: cap }, (_, i) => ({ path: `docs/n${i}.md` }));
316
+ assert.equal((await refusal(RUNNER_CLAIM, many)).code, 'self_modification_unreadable');
317
+ assert.equal(await refusal(RUNNER_CLAIM, files('docs/a.md')), null, 'a clean runner diff publishes');
318
+ });
319
+
320
+ await test('a claim read that fails lets an ordinary publish through, but not a runner\'s branch', async () => {
321
+ const failing = { query: async () => { throw new Error('db down'); } };
322
+ const claim = await guard.readLatestClaim(4242, failing);
323
+ assert.equal(claim && claim.unreadable, true, 'a failed read is not the same answer as no claim');
324
+ const viaFailedRead = (branch, prFiles) => guard.runnerSelfEditRefusal({ taskId: 4242, prNumber: 77, branch }, {
325
+ readLatestClaim: (id) => guard.readLatestClaim(id, failing), getPullRequestFiles: async () => prFiles,
326
+ });
327
+ assert.equal(await viaFailedRead('fix-the-fence', files('modules/autonomy/fence.js')), null, 'an ordinary publish never depends on the read');
328
+ assert.equal((await viaFailedRead('autobongos-4242-abc123', files('modules/autonomy/fence.js'))).code, 'self_modification', 'a runner branch is still checked');
329
+ const seen = [];
330
+ await guard.readLatestClaim(4242, { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [] }; } });
331
+ assert.match(seen[0].sql, /FROM claims c WHERE c\.task_id = \$1\s+ORDER BY c\.claimed_at DESC, c\.id DESC LIMIT 1/);
332
+ assert.deepEqual(seen[0].params, [4242]);
333
+ });
334
+
335
+ // ---- the real publish route --------------------------------------------------
336
+
337
+ const express = require('express');
338
+ const api = require('../src/module-api.js');
339
+ const db = require('../modules/lifecycle/db.js');
340
+ const gp = require('../modules/lifecycle/github-push.js');
341
+ const prAssign = require('../modules/lifecycle/pr-assign.js');
342
+ const prClose = require('../modules/lifecycle/github-pr-close.js');
343
+ const pageTweakReads = require('../modules/lifecycle/page-tweak-reads.js');
344
+
345
+ let claim = RUNNER_CLAIM;
346
+ let prFiles = files('docs/a.md');
347
+ let armCalls = 0;
348
+ let closed = [];
349
+ api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank: 'archon', github_login: 'the-archon' }; next(); };
350
+ db.getLastClaimHolder = async () => ({ builder_id: 3, github_login: 'some-builder' });
351
+ db.setClaimPublishedSha = async () => {};
352
+ api.pool.query = async (sql) => {
353
+ if (/c\.auto_pickup/.test(sql)) return { rows: [{ id: 4242, source: 'planning-session', source_ref: null, auto_pickup: null }] };
354
+ if (/c\.worktree_name, c\.requested_worktree_name/.test(sql)) return { rows: claim ? [claim] : [] };
355
+ return { rows: [] };
356
+ };
357
+ gp.isConfigured = () => true;
358
+ gp.pushBundle = async () => ({ head_sha: 'a'.repeat(40) });
359
+ gp.openOrFindPullRequest = async () => ({ number: 77, url: 'https://github.com/o/r/pull/77', node_id: 'N' });
360
+ gp.enableAutoMergeDetailed = async () => { armCalls++; return { ok: true }; };
361
+ let fileReads = 0;
362
+ gp.getPullRequestFiles = async () => { fileReads++; return prFiles; };
363
+ gp.setCommitStatus = async () => true;
364
+ prAssign.assignPullRequest = async () => ({ assigned: true });
365
+ prClose.closePullRequest = async ({ number }) => { closed.push(number); return { ok: true }; };
366
+ pageTweakReads.heldForArtistFailClosed = async () => false;
367
+
368
+ const app = express();
369
+ app.use((req, res, next) => { res.fail = (code, status, extra) => res.status(typeof status === 'number' ? status : (status && status.status) || 500).json({ error: code, ...(extra || {}) }); next(); });
370
+ app.use(require('../modules/lifecycle/routes/tasks.js')());
371
+ const server = http.createServer(app);
372
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
373
+ const base = `http://127.0.0.1:${server.address().port}`;
374
+ const publish = async () => {
375
+ const res = await fetch(`${base}/tasks/4242/publish-branch`, {
376
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
377
+ body: JSON.stringify({ branch: 'autobongos-4242-abc123', head_sha: 'a'.repeat(40), bundle_b64: Buffer.from('b').toString('base64'), value_summary: 'A fix.' }),
378
+ });
379
+ return { status: res.status, body: await res.json() };
380
+ };
381
+
382
+ await test('publish: a runner\'s undeclared self-edit is REFUSED 409, its PR closed, never armed', async () => {
383
+ claim = RUNNER_CLAIM; prFiles = files('modules/hall-ui/public/x.js', 'scripts/bongos/autobongos-run.js'); armCalls = 0; closed = [];
384
+ const r = await publish();
385
+ assert.equal(r.status, 409, JSON.stringify(r.body));
386
+ assert.equal(r.body.error, 'self_modification');
387
+ assert.deepEqual(r.body.files, ['scripts/bongos/autobongos-run.js']);
388
+ assert.match(r.body.message, /supervisor, safety checks or kill switch/);
389
+ assert.deepEqual(closed, [77]);
390
+ assert.equal(armCalls, 0);
391
+ });
392
+
393
+ await test('publish: a runner\'s clean change publishes and arms as usual', async () => {
394
+ claim = RUNNER_CLAIM; prFiles = files('modules/hall-ui/public/x.js'); armCalls = 0; closed = [];
395
+ const r = await publish();
396
+ assert.equal(r.status, 201, JSON.stringify(r.body));
397
+ assert.deepEqual(closed, []);
398
+ assert.equal(armCalls, 1);
399
+ });
400
+
401
+ await test('publish: a runner\'s auto-pickup round reads its pull request\'s files from GitHub once, for both checks', async () => {
402
+ const round = { id: 4242, source: 'page-tweak', source_ref: 'page-tweak/landing:index/r2', auto_pickup: true };
403
+ const before = api.pool.query;
404
+ api.pool.query = async (sql) => (/c\.auto_pickup/.test(sql) ? { rows: [round] } : before(sql));
405
+ claim = RUNNER_CLAIM; prFiles = files('modules/public-landing/public/index.html'); fileReads = 0; closed = [];
406
+ try {
407
+ await publish();
408
+ assert.equal(fileReads, 1, 'the auto-pickup check and the runner check share one read');
409
+ } finally { api.pool.query = before; }
410
+ });
411
+
412
+ await test('publish: a person\'s change to the same files is not checked', async () => {
413
+ claim = { worktree_name: 'fix-the-fence', requested_worktree_name: null }; prFiles = files('modules/autonomy/fence.js'); closed = [];
414
+ const r = await publish();
415
+ assert.equal(r.status, 201, JSON.stringify(r.body));
416
+ assert.deepEqual(closed, []);
417
+ });
418
+ server.close();
419
+
420
+ summary();