@bongos/core 1.19.1080 → 1.20.1

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 (258) hide show
  1. package/.bongos-core.json +573 -188
  2. package/.claude/skills/planning-session/SKILL.md +6 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +34 -0
  5. package/clients/bongos-client/index.cjs +34 -0
  6. package/clients/bongos-client/index.d.ts +53 -4
  7. package/clients/bongos-client/index.mjs +34 -0
  8. package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
  9. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  10. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
  11. package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
  12. package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
  13. package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +2 -0
  14. package/docs/adr/0347-every-store-module-ships-a-how-to.md +135 -0
  15. package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
  16. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
  17. package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
  18. package/docs/adr/README.md +4 -0
  19. package/docs/api/openapi.json +995 -40
  20. package/docs/api-reference.md +33 -8
  21. package/docs/architecture.md +6 -2
  22. package/docs/copy-inventory.md +620 -553
  23. package/docs/copy-registry.json +1467 -820
  24. package/docs/file-map.md +2 -0
  25. package/docs/module-api-changelog.md +8 -0
  26. package/docs/modules-contract.md +1 -0
  27. package/docs/page-inventory.json +38 -4
  28. package/docs/page-readings.json +1674 -1566
  29. package/migrations/core_260_goals_working_area.sql +63 -0
  30. package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
  31. package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
  32. package/modules/agents/lib/validate.js +43 -0
  33. package/modules/autonomy/gauge.js +38 -2
  34. package/modules/grading/grader-subagent.js +37 -2
  35. package/modules/grading/grader-workers/reader.js +65 -5
  36. package/modules/hall-ui/public/approval-queue.css +33 -7
  37. package/modules/hall-ui/public/approval-queue.js +9 -2
  38. package/modules/hall-ui/public/atlas.html +1 -1
  39. package/modules/hall-ui/public/blockers.html +1 -1
  40. package/modules/hall-ui/public/board-room.html +1 -1
  41. package/modules/hall-ui/public/brand-holes.js +121 -0
  42. package/modules/hall-ui/public/collab.html +1 -1
  43. package/modules/hall-ui/public/collab.js +1 -1
  44. package/modules/hall-ui/public/copy-desk.html +1 -1
  45. package/modules/hall-ui/public/deploy.html +6 -1
  46. package/modules/hall-ui/public/deploy.js +22 -8
  47. package/modules/hall-ui/public/diagrams.html +1 -1
  48. package/modules/hall-ui/public/dom-utils.js +20 -0
  49. package/modules/hall-ui/public/drachmae.html +1 -1
  50. package/modules/hall-ui/public/fleet.html +1 -1
  51. package/modules/hall-ui/public/gate.html +1 -1
  52. package/modules/hall-ui/public/goals.html +1 -1
  53. package/modules/hall-ui/public/government.html +1 -1
  54. package/modules/hall-ui/public/idea.html +1 -1
  55. package/modules/hall-ui/public/idea.js +28 -0
  56. package/modules/hall-ui/public/ideas.html +1 -1
  57. package/modules/hall-ui/public/ideas.js +19 -3
  58. package/modules/hall-ui/public/index.html +5 -1
  59. package/modules/hall-ui/public/modules.html +1 -1
  60. package/modules/hall-ui/public/primer.html +1 -1
  61. package/modules/hall-ui/public/profile-nudge.js +21 -4
  62. package/modules/hall-ui/public/profile.html +1 -1
  63. package/modules/hall-ui/public/profile.js +1 -1
  64. package/modules/hall-ui/public/project-settings.html +1 -1
  65. package/modules/hall-ui/public/ranks.html +1 -1
  66. package/modules/hall-ui/public/roadmap.html +1 -1
  67. package/modules/hall-ui/public/roster.html +1 -1
  68. package/modules/hall-ui/public/sessions.html +1 -1
  69. package/modules/hall-ui/public/settings.html +19 -30
  70. package/modules/hall-ui/public/settings.js +24 -177
  71. package/modules/hall-ui/public/settings.states.json +1 -1
  72. package/modules/hall-ui/public/shell.js +4 -2
  73. package/modules/hall-ui/public/studio.css +14 -8
  74. package/modules/hall-ui/public/studio.html +5 -4
  75. package/modules/hall-ui/public/task.html +1 -1
  76. package/modules/hall-ui/public/thinking.css +6 -3
  77. package/modules/hall-ui/public/thinking.html +1 -1
  78. package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
  79. package/modules/hall-ui/public/tweak-editor.css +142 -11
  80. package/modules/hall-ui/public/tweak-editor.html +25 -9
  81. package/modules/hall-ui/public/tweak-editor.js +243 -10
  82. package/modules/hall-ui/public/watch.html +1 -1
  83. package/modules/hall-ui/public/work.html +1 -1
  84. package/modules/hall-ui/records/approval-queue.md +6 -0
  85. package/modules/hall-ui/records/tweak-editor.md +6 -0
  86. package/modules/ideas/ratify.js +36 -8
  87. package/modules/ideas/routes/ratify-goal.js +6 -0
  88. package/modules/lifecycle/db-analytics.js +79 -10
  89. package/modules/lifecycle/db-claims.js +48 -1
  90. package/modules/lifecycle/db-deps-criteria.js +94 -40
  91. package/modules/lifecycle/db-goals.js +4 -1
  92. package/modules/lifecycle/db-grade.js +9 -1
  93. package/modules/lifecycle/db-relevance-flags.js +183 -0
  94. package/modules/lifecycle/db-versions.js +4 -3
  95. package/modules/lifecycle/db.js +19 -0
  96. package/modules/lifecycle/done-when.js +5 -3
  97. package/modules/lifecycle/est-advisory.js +31 -4
  98. package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
  99. package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
  100. package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
  101. package/modules/lifecycle/module.json +2 -1
  102. package/modules/lifecycle/routes/claims.js +8 -1
  103. package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
  104. package/modules/lifecycle/routes/goals.js +1 -1
  105. package/modules/lifecycle/routes/tasks.js +27 -24
  106. package/modules/lifecycle/routes/visuals.js +9 -0
  107. package/modules/lifecycle/ship-preflight.js +7 -11
  108. package/modules/lifecycle/task-visuals.js +109 -5
  109. package/modules/npm-release/module.json +2 -1
  110. package/modules/npm-release/preview/commands.js +81 -0
  111. package/modules/npm-release/preview/divert.js +95 -0
  112. package/modules/npm-release/preview/env.js +78 -0
  113. package/modules/npm-release/preview/proxy.js +107 -0
  114. package/modules/npm-release/preview/runtime.js +83 -0
  115. package/modules/npm-release/preview/supervisor.js +175 -0
  116. package/modules/npm-release/public/work.js +65 -1
  117. package/modules/npm-release/routes/preview.js +133 -0
  118. package/modules/provisioning/app-status.js +83 -0
  119. package/modules/provisioning/migrations/provisioning_028_render_standup.sql +46 -0
  120. package/modules/provisioning/module.json +4 -2
  121. package/modules/provisioning/pollers/app-liveness.js +110 -0
  122. package/modules/provisioning/provisioning.js +5 -5
  123. package/modules/provisioning/render-lookup.js +68 -0
  124. package/modules/provisioning/render-standup.js +134 -0
  125. package/modules/provisioning/routes/render-standup.js +165 -0
  126. package/modules/provisioning/starter-bundles.js +4 -1
  127. package/modules/public-landing/public/account-private.states.json +14 -0
  128. package/modules/public-landing/public/account.html +513 -0
  129. package/modules/public-landing/public/account.probes.json +37 -0
  130. package/modules/public-landing/public/account.states.json +16 -0
  131. package/modules/public-landing/public/index.html +5 -2
  132. package/modules/public-landing/public/projects.html +661 -18
  133. package/modules/public-landing/public/projects.states.json +8 -1
  134. package/modules/render-deploy/deploys.js +203 -0
  135. package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
  136. package/modules/render-deploy/module.json +25 -0
  137. package/modules/render-deploy/public/deploy.css +10 -0
  138. package/modules/render-deploy/public/deploy.js +249 -0
  139. package/modules/render-deploy/render.js +56 -0
  140. package/modules/render-deploy/routes/act.js +88 -0
  141. package/modules/render-deploy/routes/app.js +65 -0
  142. package/modules/render-deploy/routes/history.js +38 -0
  143. package/modules/specialities/db.js +27 -8
  144. package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
  145. package/modules/specialities/routes/specialities.js +51 -3
  146. package/modules/specialities/skills.js +94 -0
  147. package/modules/specialities/specialities.js +56 -9
  148. package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
  149. package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
  150. package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
  151. package/modules/ui-design/kit/lib.js +3 -1
  152. package/modules/ui-design/kit/serve.js +96 -0
  153. package/package-lock.json +2 -2
  154. package/package.json +1 -1
  155. package/release-notes.json +173 -0
  156. package/scripts/gds/agents-sync.js +13 -3
  157. package/scripts/gds/autobongos-run.js +80 -2
  158. package/scripts/gds/autobongos-service.cmd +12 -0
  159. package/scripts/gds/copy-apply.js +14 -0
  160. package/scripts/gds/dev-box-guard.js +3 -2
  161. package/scripts/gds/fitness.js +9 -0
  162. package/scripts/gds/grade-correlation-audit.js +42 -12
  163. package/scripts/gds/grade-replay.js +1 -1
  164. package/scripts/gds/provision-core-upgrade.js +38 -7
  165. package/scripts/gds/provision-pin-key.js +176 -0
  166. package/scripts/gds/provision-render.js +189 -0
  167. package/scripts/gds/provision-teardown.js +9 -2
  168. package/scripts/gds/provision-units.js +31 -0
  169. package/scripts/gds/provision.js +12 -12
  170. package/scripts/gds/publish-manifest.js +1 -0
  171. package/scripts/gds/render-api.js +197 -0
  172. package/scripts/gds/render-payload.js +84 -0
  173. package/scripts/gds/run-unit-tests.js +10 -0
  174. package/scripts/gds/ship-finish.js +19 -20
  175. package/scripts/gds/smoke-dependencies.sh +37 -8
  176. package/scripts/gds/status.js +12 -4
  177. package/scripts/gds/upgrade.js +2 -2
  178. package/scripts/public-mirror-export.js +7 -1
  179. package/src/bongos/module-scope-map.js +13 -0
  180. package/src/bongos/serve-internal.js +3 -1
  181. package/src/module-api.js +10 -1
  182. package/src/platform-server.js +39 -0
  183. package/tests/account_privacy_flags.mjs +10 -6
  184. package/tests/agents_authoring.mjs +15 -1
  185. package/tests/agents_sync.mjs +106 -5
  186. package/tests/agents_validate.mjs +45 -0
  187. package/tests/api_path_404.mjs +6 -0
  188. package/tests/autobongos_cadence.mjs +7 -0
  189. package/tests/autobongos_loop.mjs +136 -1
  190. package/tests/autonomy_gauge.mjs +62 -0
  191. package/tests/blocker_hall_live_proof.mjs +275 -0
  192. package/tests/claim_gate_ci_unblock.mjs +116 -0
  193. package/tests/claim_gate_rebase.mjs +15 -3
  194. package/tests/collab_page.mjs +10 -0
  195. package/tests/conductor_main_e2e.mjs +97 -0
  196. package/tests/core_262_drop_box_blocked_db.mjs +143 -0
  197. package/tests/core_upgrade_runner.mjs +1 -0
  198. package/tests/dependency_writes_atomic.mjs +191 -0
  199. package/tests/deploy_page_projects.mjs +2 -1
  200. package/tests/effective_visibility_predicate.mjs +15 -0
  201. package/tests/est_advisory.mjs +27 -0
  202. package/tests/goal_map_page.mjs +26 -0
  203. package/tests/goal_task_relevance.mjs +518 -0
  204. package/tests/goal_working_area.mjs +146 -0
  205. package/tests/grade_attempts.mjs +61 -0
  206. package/tests/grade_attribution.mjs +23 -0
  207. package/tests/grade_correlation_audit.mjs +54 -1
  208. package/tests/grader_reader_lens.mjs +55 -2
  209. package/tests/grader_root_outage.mjs +6 -1
  210. package/tests/grader_subagent_tools_arg.mjs +102 -0
  211. package/tests/hall_approval_queue.mjs +54 -2
  212. package/tests/hall_settings_world.mjs +3 -1
  213. package/tests/hall_tweak_editor.mjs +316 -11
  214. package/tests/hub_account_page.mjs +394 -0
  215. package/tests/idea_detail_page.mjs +64 -0
  216. package/tests/idea_goal_ratification.mjs +64 -0
  217. package/tests/idea_spark_hall.mjs +60 -9
  218. package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
  219. package/tests/landing_page.mjs +6 -3
  220. package/tests/module-scope-map.mjs +31 -8
  221. package/tests/module_api.mjs +1 -0
  222. package/tests/module_loader.mjs +1 -1
  223. package/tests/nav_permission_atoms.mjs +1 -1
  224. package/tests/no_phantom_mirror_workflow.mjs +24 -0
  225. package/tests/npm_release_preview_commands.mjs +89 -0
  226. package/tests/npm_release_preview_divert.mjs +164 -0
  227. package/tests/npm_release_preview_env.mjs +100 -0
  228. package/tests/npm_release_preview_proxy.mjs +162 -0
  229. package/tests/npm_release_preview_routes.mjs +208 -0
  230. package/tests/npm_release_preview_supervisor.mjs +203 -0
  231. package/tests/pin_write_key.mjs +383 -0
  232. package/tests/planning_session_skill.mjs +27 -0
  233. package/tests/platform_boot.mjs +71 -0
  234. package/tests/profile_nudge_links.mjs +86 -0
  235. package/tests/profile_ui_cross_project.mjs +1 -1
  236. package/tests/projects_hub.mjs +2 -2
  237. package/tests/projects_hub_app_status.mjs +249 -0
  238. package/tests/projects_hub_app_step.mjs +282 -52
  239. package/tests/projects_hub_render_connect.mjs +295 -0
  240. package/tests/provision_render.mjs +362 -0
  241. package/tests/provision_settings_apply.mjs +62 -0
  242. package/tests/provisioning_app_status.mjs +204 -0
  243. package/tests/provisioning_render_route.mjs +336 -0
  244. package/tests/provisioning_settings_apply.mjs +1 -1
  245. package/tests/provisioning_teardown_intent.mjs +2 -2
  246. package/tests/render_api.mjs +153 -0
  247. package/tests/render_check.mjs +2 -1
  248. package/tests/render_deploy.mjs +495 -0
  249. package/tests/ship_preflight.mjs +61 -8
  250. package/tests/smoke_dependencies_witness.mjs +46 -0
  251. package/tests/speciality_skills.mjs +214 -0
  252. package/tests/studio_room_height.mjs +70 -0
  253. package/tests/task_detail_includes.mjs +10 -0
  254. package/tests/task_visuals_instance_root.mjs +150 -0
  255. package/tests/tweak_batch_apply.mjs +21 -0
  256. package/tests/ui_design_kit.mjs +36 -4
  257. package/tests/upgrade.mjs +1 -1
  258. package/tests/upgrade_persist_pin.mjs +24 -0
@@ -167,6 +167,14 @@ function shortTitle(title, max = 52) {
167
167
  return t.length > max ? t.slice(0, max - 1) + '…' : t;
168
168
  }
169
169
 
170
+ // "area 6" for a working-area goal, else nothing (task 1004057). Read from the
171
+ // goal's working_area FIELD, never parsed from its title and never taken from
172
+ // sort_order or the id, which both look like it and are not.
173
+ function areaTag(g) {
174
+ const n = g && g.working_area;
175
+ return Number.isInteger(n) && n > 0 ? 'area ' + n : '';
176
+ }
177
+
170
178
  // "1 confirmed, 2 backlog" — non-shipped tasks grouped by status, ordered.
171
179
  function remainingSummary(remaining) {
172
180
  if (remaining.length === 0) return 'none — all shipped ✓';
@@ -283,7 +291,7 @@ function renderStatusWidget(data, criterion) {
283
291
  const rows = goals
284
292
  ? goals.map((g) => {
285
293
  const ach = g.status === 'achieved';
286
- const head = `<tr><td class="st-td" colspan="5" style="background:var(--color-background-secondary);font-weight:500;"><i class="ti ti-target" aria-hidden="true"></i> ${sesc(g.title)} <span class="st-sub">· ${ach ? '<span class="st-ok">achieved</span>' : sesc(g.status)}${g.criteria.length ? '' : ' · no criteria yet'}</span></td></tr>`;
294
+ const head = `<tr><td class="st-td" colspan="5" style="background:var(--color-background-secondary);font-weight:500;"><i class="ti ti-target" aria-hidden="true"></i> ${sesc(g.title)} <span class="st-sub">· ${areaTag(g) ? sesc(areaTag(g)) + ' · ' : ''}${ach ? '<span class="st-ok">achieved</span>' : sesc(g.status)}${g.criteria.length ? '' : ' · no criteria yet'}</span></td></tr>`;
287
295
  return head + g.criteria.map(critRow).join('');
288
296
  }).join('')
289
297
  : data.criteria.map(critRow).join('');
@@ -336,7 +344,7 @@ function renderStatusMarkdown(data, criterion) {
336
344
  const tableHeader = () => { lines.push('| | Criterion | Pos | Shipped | Remaining |'); lines.push('|---|---|---|---|---|'); };
337
345
  if (goals) {
338
346
  for (const g of goals) {
339
- lines.push(`**🎯 ${g.title}** — ${g.status === 'achieved' ? 'achieved ✓' : g.status}${g.criteria.length ? '' : ' · no criteria yet'}`);
347
+ lines.push(`**🎯 ${g.title}** — ${areaTag(g) ? areaTag(g) + ' · ' : ''}${g.status === 'achieved' ? 'achieved ✓' : g.status}${g.criteria.length ? '' : ' · no criteria yet'}`);
340
348
  if (g.criteria.length) { tableHeader(); for (const c of g.criteria) lines.push(critRow(c)); }
341
349
  lines.push('');
342
350
  }
@@ -383,7 +391,7 @@ function printAll(data) {
383
391
  };
384
392
  if (goals) {
385
393
  for (const g of goals) {
386
- console.log(` 🎯 ${g.title} [${g.status === 'achieved' ? 'achieved ✓' : g.status}]${g.criteria.length ? '' : ' (no criteria yet)'}`);
394
+ console.log(` 🎯 ${g.title} [${areaTag(g) ? areaTag(g) + ' · ' : ''}${g.status === 'achieved' ? 'achieved ✓' : g.status}]${g.criteria.length ? '' : ' (no criteria yet)'}`);
387
395
  for (const c of g.criteria) printCrit(c);
388
396
  if (g.criteria.length) console.log('');
389
397
  }
@@ -523,4 +531,4 @@ if (require.main === module) {
523
531
  });
524
532
  }
525
533
 
526
- module.exports = { parseArgs, resolveVersion, defaultedVersionNote, positionalRefNote, renderStatusWidget, renderStatusMarkdown };
534
+ module.exports = { parseArgs, resolveVersion, defaultedVersionNote, positionalRefNote, renderStatusWidget, renderStatusMarkdown, areaTag };
@@ -127,9 +127,9 @@ function restorePin(instanceDir, depString, fsImpl = fs) {
127
127
 
128
128
  // ---- steps (each injectable for tests) --------------------------------------
129
129
 
130
- // `ignore` is waived (it comes from — and is argued for in — claude-materialize.js materializeWillRewrite, task 1004122); every OTHER dirty path still halts the bump.
130
+ // `ignore` is waived (it comes from — and is argued for in — claude-materialize.js materializeWillRewrite, task 1004122); every OTHER dirty path still halts the bump. -uall (task 1004188): a bare status collapses a NEW untracked dir to one '?? dir/' entry the file-path waiver never matches.
131
131
  function gitTreeClean(instanceDir, run = spawnSync, ignore = []) {
132
- const r = run('git', ['status', '--porcelain'], { cwd: instanceDir, encoding: 'utf8' });
132
+ const r = run('git', ['status', '--porcelain', '-uall'], { cwd: instanceDir, encoding: 'utf8' });
133
133
  if (!r || r.error || r.status !== 0) return { ok: false, reason: 'git status failed (not a git repo?)' };
134
134
  const skip = new Set(ignore), all = (r.stdout || '').split('\n').filter((l) => l.trim());
135
135
  const kept = all.filter((l) => !skip.has(l.slice(3).trim()));
@@ -22,7 +22,13 @@
22
22
  //
23
23
  // The 6-month delay alone does NOT scrub secrets/topology — redaction is mandatory and
24
24
  // the gate is the backstop. Reuses the status-mirror-sync transport shape (--repo-dir /
25
- // --push + a scoped deploy key, see .github/workflows/sync-public-mirror.yml).
25
+ // --push + a scoped deploy key).
26
+ //
27
+ // PUBLISHING IS A MANUAL STEP. No workflow or schedule runs this script (there is
28
+ // no sync-public-mirror workflow) and nothing publishes the public mirror automatically.
29
+ // A person runs it by hand, e.g.
30
+ // node scripts/public-mirror-export.js --repo-dir <mirror-checkout> --push
31
+ // (a scheduled public release channel needs the owner's approval first).
26
32
  //
27
33
  // USAGE:
28
34
  // node scripts/public-mirror-export.js --repo-dir <mirror-checkout> [--push]
@@ -73,6 +73,16 @@ const MODULE_GLOBS = {
73
73
  // protected - ADR 0314 in a second costume. Ownership follows what the
74
74
  // file IS, and what it is is the core read facade over ten modules.
75
75
  'src/bongos/routes/me.js',
76
+ // The rest of the core route directory (task 1004116). It is a BLANKET
77
+ // protected glob, so every file in it is walled, but until now only the two
78
+ // entries above were authorable: sixteen live routes (auth, public,
79
+ // core-update, builders, ...) sat in no module's territory and no goal could
80
+ // author a task on them. They are the core HTTP surface that stayed in core
81
+ // while domain routes carved into modules/<key>/routes/, so they are kernel's.
82
+ // Kernel was already protected, so this changes no module's protected status;
83
+ // the me.js overlap the task feared is gone (task 1004110 moved it here too).
84
+ // The two explicit entries above stay for their reasons.
85
+ 'src/bongos/routes/',
76
86
  'server.js', 'config/',
77
87
 
78
88
  // ADOPTED (task 1004111): a protected path owned by no module is authorable
@@ -340,6 +350,9 @@ const MODULE_GLOBS = {
340
350
  // (tests/npm_release_*.mjs) because a default-off module's own tests/ is skipped
341
351
  // by the unit runner. The /deploy page shell stays hall-ui's (one key per surface).
342
352
  'npm-release': ['modules/npm-release/', 'tests/npm_release_*.mjs'],
353
+ // --- the render-deploy module (task 1004270): a Render project's deploy page. Tests live at
354
+ // the repo root (tests/render_deploy_*.mjs) for the same reason as npm-release above.
355
+ 'render-deploy': ['modules/render-deploy/', 'tests/render_deploy_*.mjs'],
343
356
  // --- instance provisioning: the web-tier REQUEST + STATE surface (module home)
344
357
  // plus the control-plane runner CLI — a command travels with its domain (the
345
358
  // lifecycle precedent). Covers the projects-surface work (goal
@@ -986,7 +986,9 @@ function mountInternalSurfaces(app) {
986
986
  if (req.method !== 'GET' && req.method !== 'HEAD') return next();
987
987
  if (!DEPLOY_PAGE_RE.test(path.posix.normalize(req.path))) return next();
988
988
  let door = false;
989
- try { door = isModuleEnabled('provisioning'); } catch { door = false; }
989
+ // The page also exists where the render-deploy module is on: a Render project's own deploy
990
+ // page (task 1004270). Neither module on and the page still answers with Settings.
991
+ try { door = isModuleEnabled('provisioning') || isModuleEnabled('render-deploy'); } catch { door = false; }
990
992
  if (door) return next();
991
993
  return res.redirect(302, `${isBuildersHost(req) ? '' : '/builders'}/settings#software-update`);
992
994
  });
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.19.1080'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.1'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -255,6 +255,15 @@ module.exports = {
255
255
  // second protected list — so a module reaches it via the doorway, not a deep
256
256
  // core require (which the module-boundary fitness check would red). PERMANENT.
257
257
  get goalScopeCheck() { return require('./bongos/goal-scope-check'); },
258
+ // cascadeDispatch — the cheapest-model-first cascade router (src/bongos/
259
+ // cascade-dispatch.js, task 1001691 / ADR 0101 Part 1). KERNEL (it composes
260
+ // no domain state — a pure ladder + an injected runner), grandfathered
261
+ // dormant core since System 3 (grading/CLAUDE.md's own note: "stays in
262
+ // src/gds/... nothing calls it but its mock unit test"). The lifecycle
263
+ // module's relevance/anti-hack judge (goal-task-relevance-judge.js) is its
264
+ // FIRST real caller, injecting grade.runSubagent (resolved via the `grade`
265
+ // port, never required directly) as the runner — never a deep core require.
266
+ get cascadeDispatch() { return require('./bongos/cascade-dispatch'); },
258
267
 
259
268
  // === DOMAIN capabilities still on the doorway — SCHEDULED TO RELOCATE ======
260
269
  // NOT kernel: these leak a domain's queries through the doorway (ADR 0091
@@ -57,6 +57,7 @@ const { isModuleEnabled } = require('./modules');
57
57
  const { refreshOverrides } = require('./bongos/module-overrides');
58
58
  const { schemaPending } = require('./bongos/schema-lag');
59
59
  const { logger, requestIdMiddleware } = require('./bongos/logger');
60
+ const seams = require('./module-seams');
60
61
 
61
62
  const PORT = Number(process.env.PORT) || 3000;
62
63
  const HOST = process.env.HOST || '127.0.0.1';
@@ -199,6 +200,32 @@ function buildPlatformApp() {
199
200
  // same reqId. Mounted before any surface so nothing logs uncorrelated.
200
201
  app.use(requestIdMiddleware);
201
202
 
203
+ // THE request.divert SEAM (task 1004300, ADR 0349). A module may register a provider that
204
+ // takes a request BEFORE any surface answers and either handles it or passes it on —
205
+ // today the npm-release module's version preview, which hands a browser holding its
206
+ // cookie to a second process. The provider is looked up on EVERY request, not once at
207
+ // build: modules mount inside mountInternalSurfaces below, after this line runs, so a
208
+ // lookup here at build time would always find nothing.
209
+ //
210
+ // WITH NO PROVIDER THIS IS ONE MAP LOOKUP AND next(): an instance that does not have such
211
+ // a module is served exactly as it was. A provider that throws is treated as absent for
212
+ // that request, never as a failed request — a broken preview must not take the live hall
213
+ // down with it — unless it had already started answering.
214
+ app.use((req, res, next) => {
215
+ const divert = seams.resolveOptional('request.divert');
216
+ if (!divert) return next();
217
+ let passed = false;
218
+ const pass = (e) => { passed = true; next(e); };
219
+ Promise.resolve()
220
+ .then(() => divert(req, res, pass))
221
+ .catch((err) => {
222
+ logger.error({ err }, 'request.divert provider failed — serving the request normally');
223
+ if (passed) return;
224
+ if (res.headersSent) { res.destroy(); return; }
225
+ pass();
226
+ });
227
+ });
228
+
202
229
  // The whole internal surface — shared verbatim with server.js (ADR 0062 R51).
203
230
  mountInternalSurfaces(app);
204
231
 
@@ -266,6 +293,18 @@ function buildPlatformApp() {
266
293
  res.type('html').send(html);
267
294
  });
268
295
  }
296
+ // THE ACCOUNT PAGE (task 1004259, ADR 0337 D4.3): where a person edits what
297
+ // belongs to their platform account rather than to one project — handle, name,
298
+ // bio, links, and who can find them. Hub-only, unlike the doors above: every
299
+ // read and write it makes is a platform-identity route, and that module IS the
300
+ // hub flag (the stripApexOnly note above), so a child instance serves no such
301
+ // page rather than one whose every request 404s.
302
+ app.get('/account', (req, res, next) => {
303
+ if (!isApexHost(req) || !isModuleEnabled('platform-identity')) return next();
304
+ const html = landingPage('account.html');
305
+ if (!html) return next();
306
+ res.type('html').send(html);
307
+ });
269
308
  // Assets fall through to the 404 tail when no module claims the apex root — the
270
309
  // handler is not mounted at all rather than pointed at a missing directory.
271
310
  const landingAssets = landingDir() && express.static(landingDir(), { index: false, fallthrough: true });
@@ -335,9 +335,13 @@ const GRANT_WORDS = {
335
335
  active_recently: /active in the last 30 days/,
336
336
  };
337
337
 
338
+ // The toggles live on the hub's account page since task 1004259 (ADR 0337 D4.3) —
339
+ // one inline-script page, so the copy and the markup are read from the same file.
340
+ const ACCOUNT_PAGE = join(ROOT, 'modules/public-landing/public/account.html');
341
+
338
342
  test('the discoverable toggle names EXACTLY what it grants — checked against SLIVER_KEYS itself', () => {
339
- const js = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
340
- const html = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
343
+ const js = readFileSync(ACCOUNT_PAGE, 'utf8');
344
+ const html = js;
341
345
  // Privacy spec D3 makes this a MUST: "The discoverable toggle's settings copy
342
346
  // must state exactly this grant." It was checked against three transcribed
343
347
  // phrases, and the transcription had gone stale in the direction that matters:
@@ -363,13 +367,13 @@ test('the discoverable toggle names EXACTLY what it grants — checked against S
363
367
  'and the copy says what it does NOT grant');
364
368
  assert.match(js, /the one thing a recruiter can see while your profile page is private/,
365
369
  'a private account is told the sliver is the exception to "not found to every visitor" — the surprising half of D2/D3');
366
- assert.match(html, /<section[^>]*id="privacy-scroll"[^>]*hidden>/,
367
- 'the section ships hidden — it reveals only where the hub module answers');
370
+ assert.match(html, /<div id="acctBody" hidden>/,
371
+ 'the controls ship hidden — they reveal only once the hub module answers for this account');
368
372
  });
369
373
 
370
374
  test('each toggle claims exactly what is enforced — and BOTH are now in force', () => {
371
- const js = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
372
- const html = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
375
+ const js = readFileSync(ACCOUNT_PAGE, 'utf8');
376
+ const html = js;
373
377
  // A toggle may promise only what a read path actually enforces. The two rows
374
378
  // reached that point one task apart. Task 1002967 wired VISIBILITY into every
375
379
  // public read, so its row moved to the present tense — and it must also say
@@ -113,8 +113,10 @@ t('FAIL-CLOSED: a throwing matcher is a hit, and only an explicit false clears a
113
113
 
114
114
  t('this wall is STRICTER than the validator, which is the point', () => {
115
115
  // An Archon clears validate.js's Metic+ rank floor on a protected scope...
116
+ // Through mergeForValidation, as the PATCH route does: the validator refuses
117
+ // a stored row's server-owned columns as unknown fields (task 1004007).
116
118
  const verdict = validate.validateAgentDefinition(
117
- { ...DB_ROW, scope_modules: ['government'] },
119
+ authoring.mergeForValidation(DB_ROW, { scope_modules: ['government'] }),
118
120
  { authorRank: 'archon', allowedModules: ['government'], protectedModules: PROTECTED_MODULES, isProtectedPath: () => false },
119
121
  );
120
122
  assert.equal(verdict.ok, true, 'the rank floor admits an Archon');
@@ -126,6 +128,18 @@ t('this wall is STRICTER than the validator, which is the point', () => {
126
128
  );
127
129
  });
128
130
 
131
+ t('the fields the routes let through are exactly the fields the validator accepts (task 1004007)', () => {
132
+ // The validator refuses any key outside its list; the routes project onto
133
+ // AUTHORABLE_FIELDS. If the two drift, either an authorable field is refused
134
+ // on every write, or the validator's list names something no route can set.
135
+ const v = validate.validateAgentDefinition(
136
+ authoring.mergeForValidation(DB_ROW, {}),
137
+ { authorRank: 'metic', allowedModules: [], protectedModules: [] },
138
+ );
139
+ assert.equal(v.ok, true, JSON.stringify(v.errors));
140
+ assert.deepEqual([...Object.keys(v.value)].sort(), [...authoring.AUTHORABLE_FIELDS].sort());
141
+ });
142
+
129
143
  console.log('\nrule 4 — the sync owns its own rows:');
130
144
 
131
145
  t('a file-sourced definition is not editable here, and the reason names the file', () => {
@@ -140,12 +140,22 @@ t('a file claiming author_rank cannot promote itself', () => {
140
140
  const p = sync.planOne(claiming, {
141
141
  authorRank: 'xenos', allowedModules: ['government'], protectedModules: ['government'],
142
142
  });
143
- assert.equal(p.action, 'flagged', 'the declared rank did not open the protected scope');
143
+ // Since task 1004007 the file is not even imported: author_rank is not a field
144
+ // of a definition, so the validator refuses it BY NAME rather than dropping it.
145
+ // That is a stronger form of this rule than the flagged import it used to be.
146
+ assert.equal(p.action, 'skip', 'a file claiming author_rank is refused, not imported');
147
+ assert.match(p.reason, /author_rank/);
144
148
  // NULL, not 'xenos': this definition reaches a protected module, so no
145
- // git-derived rank is stamped at all (stampableAuthorRank). The file's claim
146
- // loses, and so does the spoofable git value — both by the same rule.
147
- assert.equal(p.authorRank, null, 'nothing git-derived is stamped on a protected-scope row');
148
- assert.match(p.scopeViolation, /metic\+ author/i);
149
+ // git-derived rank is stamped at all (stampableAuthorRank).
150
+ assert.equal(p.authorRank ?? null, null, 'nothing git-derived is stamped on a protected-scope row');
151
+ // And without the claim, the same scope still imports only flagged: the
152
+ // protected wall is what holds, not the refusal above.
153
+ const plain = sync.planOne(
154
+ sync.parseAgentFile('---\nname: x\ntrigger: on-demand\nscope_modules: [government]\n---\nbody', {}),
155
+ { authorRank: 'xenos', allowedModules: ['government'], protectedModules: ['government'] },
156
+ );
157
+ assert.equal(plain.action, 'flagged');
158
+ assert.match(plain.scopeViolation, /metic\+ author/i);
149
159
  });
150
160
 
151
161
  t('THE ESCALATION PATH: a spoofed git identity cannot arm a protected scope', () => {
@@ -447,5 +457,96 @@ t('migrate.sh invokes agents-sync, module-gated and fail-open', () => {
447
457
  assert.doesNotMatch(call.slice(0, 200), /exit 1/, 'no exit 1 on the agents-sync path');
448
458
  });
449
459
 
460
+ // ---- task 1004007: a file-declared filter and sink SURVIVE the round trip ----
461
+ //
462
+ // Tested at the round-trip altitude on purpose: file text in, the VALIDATOR's
463
+ // normalized row out, and then the event router's own reading of that row. A
464
+ // test over parseAgentFile alone stayed green through the whole bug, because
465
+ // the parser kept `touches` — it was the validator that dropped it.
466
+ {
467
+ const router = require('../modules/agents/pollers/event-router.js');
468
+ const compile = (globs) => (file) => globs.some((g) => file.startsWith(g.replace(/\*\*.*$/, '')));
469
+ const roundTrip = (text, name = 'review-lens') => validate.validateAgentDefinition(
470
+ sync.parseAgentFile(text, { name }),
471
+ { authorRank: 'archon', allowedModules: [], protectedModules: [], isProtectedPath: () => false },
472
+ );
473
+ const LINES = [
474
+ '---', 'title: Review lens', 'trigger: event', 'event: task.shipped',
475
+ 'touches:', ' - docs/**', ' - modules/hall-ui/**', 'sink: discord', '---',
476
+ 'You review what shipped.', '',
477
+ ];
478
+ const FILE = LINES.join('\n');
479
+ const without = (...drop) => LINES.filter((l) => !drop.includes(l)).join('\n');
480
+
481
+ t('a file-declared touches[] filter reaches trigger_spec and the router reads it (task 1004007)', () => {
482
+ const v = roundTrip(FILE);
483
+ assert.equal(v.ok, true, JSON.stringify(v.errors));
484
+ assert.deepEqual(v.value.trigger_spec.touches, ['docs/**', 'modules/hall-ui/**']);
485
+ assert.deepEqual(router.declaredGlobs(v.value), ['docs/**', 'modules/hall-ui/**']);
486
+ // The point: a ship that touched nothing in scope must NOT fire.
487
+ assert.equal(router.matchesTouches(v.value, ['src/bongos/db.js'], compile).match, false);
488
+ assert.equal(router.matchesTouches(v.value, ['docs/voice.md'], compile).match, true);
489
+ });
490
+
491
+ t('a file-declared sink reaches trigger_spec and the router routes to it (task 1004007)', () => {
492
+ const v = roundTrip(FILE);
493
+ assert.equal(v.value.trigger_spec.sink, 'discord');
494
+ assert.equal(router.sinkNameFor(v.value), 'discord');
495
+ });
496
+
497
+ t('touches_globs is the same filter as touches (the router reads both spellings)', () => {
498
+ const v = roundTrip(FILE.replace('touches:', 'touches_globs:'));
499
+ assert.equal(v.ok, true, JSON.stringify(v.errors));
500
+ assert.deepEqual(router.declaredGlobs(v.value), ['docs/**', 'modules/hall-ui/**']);
501
+ });
502
+
503
+ t('a file-declared cadence reaches trigger_spec, so a cron agent can be written as a file', () => {
504
+ const v = roundTrip(['---', 'trigger: cron', 'cadence: daily', '---', 'A digest.'].join('\n'), 'digest');
505
+ assert.equal(v.ok, true, JSON.stringify(v.errors));
506
+ assert.equal(v.value.trigger_spec.cadence, 'daily');
507
+ });
508
+
509
+ t('a definition with no filter still means every ship: the default is unchanged', () => {
510
+ const v = roundTrip(without('touches:', ' - docs/**', ' - modules/hall-ui/**'));
511
+ assert.equal(v.ok, true, JSON.stringify(v.errors));
512
+ assert.equal(v.value.trigger_spec.touches, undefined);
513
+ assert.equal(router.matchesTouches(v.value, ['src/bongos/db.js'], compile).reason, 'no_filter_declared');
514
+ });
515
+
516
+ t('a malformed filter is REFUSED, never widened silently to fire on every ship', () => {
517
+ // A scalar is what "touches: docs/**" parses to. The router treats a non-list
518
+ // as ABSENT, i.e. every ship, so it has to be caught at authoring time.
519
+ const text = without(' - docs/**', ' - modules/hall-ui/**').replace('touches:', 'touches: docs/**');
520
+ const v = roundTrip(text);
521
+ assert.equal(v.ok, false);
522
+ assert.ok(v.errors.some((e) => e.code === 'trigger_touches_invalid'), JSON.stringify(v.errors));
523
+ });
524
+
525
+ t('an unknown top-level key is refused, not dropped: a typo is an error, not a default', () => {
526
+ const v = roundTrip(FILE.replace('sink: discord', 'sink: discord\ntouchess: [docs/**]'));
527
+ assert.equal(v.ok, false);
528
+ const e = v.errors.find((x) => x.code === 'unknown_field');
529
+ assert.ok(e, JSON.stringify(v.errors));
530
+ assert.match(e.message, /touchess/);
531
+ // and the exact probe from the task: a top-level touches on a row handed to
532
+ // the validator directly no longer returns ok with the filter gone.
533
+ const direct = validate.validateAgentDefinition(
534
+ { name: 'x', persona: 'p', trigger_type: 'event', trigger_spec: { event: 'task.shipped' }, touches: ['src/**'] },
535
+ { authorRank: 'archon', allowedModules: [], protectedModules: [] },
536
+ );
537
+ assert.equal(direct.ok, false, 'a touches that lands nowhere must not pass silently');
538
+ });
539
+
540
+ t('every agent file this repo ships still validates under the stricter check', () => {
541
+ const dir = path.join(process.cwd(), '.claude', 'agents');
542
+ const files = fs.readdirSync(dir).filter((x) => x.endsWith('.md'));
543
+ assert.ok(files.length >= 3, 'expected the shipped agent files');
544
+ for (const x of files) {
545
+ const v = roundTrip(fs.readFileSync(path.join(dir, x), 'utf8'), x.replace(/\.md$/, ''));
546
+ assert.equal(v.ok, true, x + ': ' + JSON.stringify(v.errors));
547
+ }
548
+ });
549
+ }
550
+
450
551
  console.log(`\nagents_sync: ${passed} passed, ${failed} failed`);
451
552
  process.exit(failed ? 1 : 0);
@@ -477,6 +477,51 @@ await test('a hostile getter cannot escape the validator', () => {
477
477
  assert.equal(r.ok, false);
478
478
  });
479
479
 
480
+ await test('an unknown top-level key is refused by name, not dropped (task 1004007)', () => {
481
+ // The validator's normalized row names a fixed set of fields, so an extra key
482
+ // used to pass with ok:true and vanish, which is how a file's top-level
483
+ // touches: filter became "fires on every ship".
484
+ const r = validateAgentDefinition(
485
+ { name: 'historian', persona: 'x', trigger_type: 'event', trigger_spec: { event: 'task.shipped' }, touches: ['src/**'] },
486
+ OPTS,
487
+ );
488
+ assert.equal(r.ok, false);
489
+ const e = r.errors.find((x) => x.code === 'unknown_field');
490
+ assert.ok(e, JSON.stringify(codes(r)));
491
+ assert.equal(e.field, 'touches');
492
+ assert.equal(r.value, null);
493
+ });
494
+
495
+ await test('a malformed touches filter is refused, because the router reads it as "every ship" (task 1004007)', () => {
496
+ for (const bad of ['docs/**', [], [''], [3], { a: 1 }]) {
497
+ const r = validateAgentDefinition(
498
+ { name: 'historian', persona: 'x', trigger_type: 'event', trigger_spec: { event: 'task.shipped', touches: bad } },
499
+ OPTS,
500
+ );
501
+ assert.equal(r.ok, false, JSON.stringify(bad));
502
+ assert.ok(codes(r).includes('trigger_touches_invalid'), JSON.stringify(bad) + ' ' + JSON.stringify(codes(r)));
503
+ }
504
+ const good = validateAgentDefinition(
505
+ { name: 'historian', persona: 'x', trigger_type: 'event', trigger_spec: { event: 'task.shipped', touches_globs: ['docs/**'] } },
506
+ OPTS,
507
+ );
508
+ assert.equal(good.ok, true, JSON.stringify(codes(good)));
509
+ });
510
+
511
+ await test('a Proxy whose ownKeys trap throws cannot escape the validator (task 1004007)', () => {
512
+ // The unknown-field check is the one place the validator enumerates the
513
+ // definition, so it is the one place a throwing ownKeys trap could escape.
514
+ const hostile = new Proxy(
515
+ { name: 'historian', persona: 'x', trigger_type: 'on-demand' },
516
+ { ownKeys() { throw new Error('boom'); } },
517
+ );
518
+ let r;
519
+ assert.doesNotThrow(() => { r = validateAgentDefinition(hostile, OPTS); });
520
+ assert.equal(r.ok, false);
521
+ assert.ok(codes(r).includes('keys_unreadable'), JSON.stringify(codes(r)));
522
+ assert.equal(r.value, null);
523
+ });
524
+
480
525
  await test('EVERY error carries a code, a field and a human message', () => {
481
526
  const r = validateAgentDefinition(
482
527
  { name: 'BAD NAME', trigger_type: 'webhook', trigger_spec: 'nope', scope_modules: ['government'] },
@@ -172,5 +172,11 @@ t('rebaseRequiredHint: still leads with /strand-fix — merge is the manual fall
172
172
  'the command that works on any machine must still come first (task 1003768)');
173
173
  });
174
174
 
175
+ // task 1004370: when main's CI is red for a reason outside every branch, the gated
176
+ // builder must learn from the refusal itself that the fix is still claimable.
177
+ t('rebaseRequiredHint: names the CI-unblock exit the gate lets through', () => {
178
+ assert.match(rebaseRequiredHint([{ id: 7 }]), /P1 kind=infra task passes this gate/);
179
+ });
180
+
175
181
  console.log(`\napi_path_404: ${passed} passed, ${failed} failed`);
176
182
  process.exit(failed ? 1 : 0);
@@ -270,6 +270,13 @@ function loopHarness({ rows, maxLoops = 6 }) {
270
270
  // network call is not a unit suite, it is a flake waiting for a slow day.
271
271
  writeHeartbeat: () => true,
272
272
  postHeartbeat: async () => true,
273
+ // These tests drive the BACKOFF LADDER, not the upgrade path (task 1004374).
274
+ // forever() checks for code drift once a loop, and the generic run() stub
275
+ // above answers rev-parse and ls-remote with different output — which reads
276
+ // as "main moved" and exits the loop before it can ever reach probing.
277
+ // Declaring no drift keeps each test about the one thing it is testing;
278
+ // tests/autobongos_loop.mjs covers the drift exit itself.
279
+ codeDrifted: async () => false,
273
280
  };
274
281
  return { deps, waits: emitted, recorded, opts: { goals: [], maxTasks: 3, maxLoops } };
275
282
  }
@@ -664,7 +664,7 @@ test('workerEnv passes Claude auth through, and still refuses the rest', () => {
664
664
  // cap has to work, and it has to survive a restart: the count comes from the
665
665
  // run log, not from memory.
666
666
 
667
- import { mkdtempSync, writeFileSync } from 'node:fs';
667
+ import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs';
668
668
  import { tmpdir } from 'node:os';
669
669
  import { join } from 'node:path';
670
670
 
@@ -768,3 +768,138 @@ test('countWorkedSince stops at the first row older than the mark', () => {
768
768
  assert.equal(runner.countWorkedSince(8000), 1);
769
769
  delete process.env.AUTOBONGOS_LOG_FILE;
770
770
  });
771
+
772
+ // --- a --forever runner must be able to take a fix (task 1004374) -----------
773
+ //
774
+ // autobongos-service.cmd refreshes the checkout only BEFORE launching node, and
775
+ // --forever never returns (task 1003904). Together: a process never re-reads its
776
+ // code, a shipped fix never reaches the runner, and nothing restarts it. On the
777
+ // host this ran week-old code for four days across three landed fixes while the
778
+ // heartbeat reported it alive and the hall showed a healthy runner.
779
+
780
+ test('codeDrifted reports a drift when main has moved past the loaded head', async () => {
781
+ const calls = [];
782
+ const run = async (bin, args) => {
783
+ calls.push(args.join(' '));
784
+ if (args[0] === 'rev-parse') return { ok: true, stdout: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' };
785
+ return { ok: true, stdout: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\trefs/heads/main\n' };
786
+ };
787
+ const d = await runner.codeDrifted({ run });
788
+ assert.ok(d, 'a moved main is a drift');
789
+ assert.equal(d.from, 'aaaaaaaa');
790
+ assert.equal(d.to, 'bbbbbbbb');
791
+ });
792
+
793
+ test('codeDrifted is false when the loaded head IS the tip', async () => {
794
+ const loadedHead = async () => 'cccccccccccccccccccccccccccccccccccccccc';
795
+ const run = async (bin, args) => (args[0] === 'rev-parse'
796
+ ? { ok: true, stdout: 'cccccccccccccccccccccccccccccccccccccccc\n' }
797
+ : { ok: true, stdout: 'cccccccccccccccccccccccccccccccccccccccc\trefs/heads/main\n' });
798
+ assert.equal(await runner.codeDrifted({ run, loadedHead }), false);
799
+ });
800
+
801
+ test('codeDrifted returns null when the LOCAL head cannot be read', async () => {
802
+ // Injected rather than stubbed through run(): loadedHead caches into a
803
+ // module-level, and an earlier test in this file already populated it — so a
804
+ // run() stub that fails rev-parse is never consulted, and the case passes for
805
+ // an unrelated reason. That is exactly what the first cut of this test did.
806
+ const okTip = async () => ({ ok: true, stdout: 'dddddddddddddddddddddddddddddddddddddddd refs/heads/main' });
807
+ assert.equal(await runner.codeDrifted({ loadedHead: async () => null, run: okTip }), null,
808
+ 'no local head means we cannot tell, and cannot-tell must never cycle the runner');
809
+ });
810
+
811
+ test('codeDrifted returns null when ORIGIN cannot be reached', async () => {
812
+ assert.equal(await runner.codeDrifted({ loadedHead: async () => 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', run: async () => ({ ok: false }) }), null);
813
+ });
814
+
815
+ test('codeDrifted returns null when the git call throws outright', async () => {
816
+ const thrown = async () => { throw new Error('no network'); };
817
+ assert.equal(await runner.codeDrifted({ loadedHead: async () => 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', run: thrown }), null);
818
+ });
819
+
820
+ test('forever EXITS with the upgrade code on drift, and logs why', async () => {
821
+ const rows = [];
822
+ const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 3 }, {
823
+ iteration: async () => ({ event: 'hold', reason: 'test' }),
824
+ reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true }),
825
+ waitOrJump: async () => ({ waited: 0, jumped: false }),
826
+ writeHeartbeat: () => true,
827
+ postHeartbeat: async () => true,
828
+ emit: () => {},
829
+ record: (r) => { rows.push(r); return r; },
830
+ now: () => Date.now() + runner.MIN_UPTIME_BEFORE_UPGRADE_MS + 1000,
831
+ codeDrifted: async () => ({ from: '1111aaaa', to: '2222bbbb' }),
832
+ });
833
+ assert.equal(code, runner.UPGRADE_EXIT_CODE, 'the wrapper loops on exactly this code');
834
+ const exit = rows.find((r) => r.event === 'upgrade_exit');
835
+ assert.ok(exit, 'a silent exit is the failure mode task 1003904 forbids — it must say why');
836
+ assert.match(exit.reason, /main moved from 1111aaaa to 2222bbbb/);
837
+ });
838
+
839
+ test('forever does NOT exit while the code is current', async () => {
840
+ const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2 }, {
841
+ iteration: async () => ({ event: 'hold', reason: 'test' }),
842
+ reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true }),
843
+ waitOrJump: async () => ({ waited: 0, jumped: false }),
844
+ writeHeartbeat: () => true,
845
+ postHeartbeat: async () => true,
846
+ emit: () => {},
847
+ record: (r) => r,
848
+ codeDrifted: async () => false,
849
+ });
850
+ assert.equal(code, 0, 'the no-terminal-stop rule still holds for every other case');
851
+ });
852
+
853
+ test('the wrapper loops on the upgrade code and only on that code', () => {
854
+ const raw = readFileSync(new URL('../scripts/gds/autobongos-service.cmd', import.meta.url), 'utf8');
855
+ // LIVE lines only, and no regex here on purpose. A first cut of this test
856
+ // matched the raw file text and passed happily against a commented-out
857
+ // "REM goto run" — which is precisely the regression it exists to catch.
858
+ const live = raw
859
+ .split('\n')
860
+ .map((l) => l.replace('\r', '').trim())
861
+ .filter((l) => l && !l.toUpperCase().startsWith('REM ') && !l.startsWith('::'));
862
+ const joined = live.join('\n');
863
+ assert.ok(live.includes(':run'), 'there must be a live label to go back to');
864
+ assert.ok(live.some((l) => l.startsWith('if "%EXITCODE%"=="75"')), 'and a live branch on the upgrade code');
865
+ assert.ok(live.includes('goto run'), 'and a live relaunch, not a commented-out one');
866
+ // Ordering: the relaunch must precede the final exit, or it can never run.
867
+ assert.ok(joined.indexOf('goto run') < joined.indexOf('exit /b %EXITCODE%'));
868
+ // And the refresh must sit INSIDE the loop, or a relaunch re-runs the old code,
869
+ // which is the entire bug.
870
+ assert.ok(joined.indexOf(':run') < joined.indexOf('git reset --quiet --hard origin/main'));
871
+ });
872
+
873
+ test('a runner that just started will NOT upgrade-exit, so a failed fetch cannot spin', async () => {
874
+ // The wrapper's reset is best-effort by design. Without this guard, starting
875
+ // behind means: see drift, exit, relaunch into the same state, forever.
876
+ let askedForDrift = false;
877
+ const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2 }, {
878
+ iteration: async () => ({ event: 'hold', reason: 'test' }),
879
+ reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true }),
880
+ waitOrJump: async () => ({ waited: 0, jumped: false }),
881
+ writeHeartbeat: () => true,
882
+ postHeartbeat: async () => true,
883
+ emit: () => {},
884
+ record: (r) => r,
885
+ now: () => Date.now(), // uptime is ~0, well under the floor
886
+ codeDrifted: async () => { askedForDrift = true; return { from: 'aaaa1111', to: 'bbbb2222' }; },
887
+ });
888
+ assert.equal(code, 0, 'it must keep running rather than bounce');
889
+ assert.equal(askedForDrift, false, 'and not even ask — the check costs a network call');
890
+ });
891
+
892
+ test('past the uptime floor, a drift still exits', async () => {
893
+ const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2 }, {
894
+ iteration: async () => ({ event: 'hold', reason: 'test' }),
895
+ reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true }),
896
+ waitOrJump: async () => ({ waited: 0, jumped: false }),
897
+ writeHeartbeat: () => true,
898
+ postHeartbeat: async () => true,
899
+ emit: () => {},
900
+ record: (r) => r,
901
+ now: () => Date.now() + runner.MIN_UPTIME_BEFORE_UPGRADE_MS + 1000,
902
+ codeDrifted: async () => ({ from: 'aaaa1111', to: 'bbbb2222' }),
903
+ });
904
+ assert.equal(code, runner.UPGRADE_EXIT_CODE);
905
+ });