@bongos/core 1.19.1081 → 1.20.2

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 (253) hide show
  1. package/.bongos-core.json +528 -193
  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 +32 -0
  5. package/clients/bongos-client/index.cjs +32 -0
  6. package/clients/bongos-client/index.d.ts +51 -6
  7. package/clients/bongos-client/index.mjs +32 -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/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
  14. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
  15. package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
  16. package/docs/adr/README.md +3 -0
  17. package/docs/api/openapi.json +851 -19
  18. package/docs/api-reference.md +33 -9
  19. package/docs/architecture.md +6 -2
  20. package/docs/copy-inventory.md +620 -553
  21. package/docs/copy-registry.json +1467 -820
  22. package/docs/file-map.md +2 -0
  23. package/docs/module-api-changelog.md +8 -0
  24. package/docs/modules-contract.md +1 -0
  25. package/docs/page-inventory.json +38 -4
  26. package/docs/page-readings.json +1666 -1558
  27. package/migrations/core_260_goals_working_area.sql +63 -0
  28. package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
  29. package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
  30. package/modules/agents/lib/validate.js +43 -0
  31. package/modules/autonomy/gauge.js +38 -2
  32. package/modules/grading/grader-subagent.js +37 -2
  33. package/modules/grading/grader-workers/reader.js +65 -5
  34. package/modules/hall-ui/public/approval-queue.css +33 -7
  35. package/modules/hall-ui/public/approval-queue.js +9 -2
  36. package/modules/hall-ui/public/atlas.html +1 -1
  37. package/modules/hall-ui/public/blockers.html +1 -1
  38. package/modules/hall-ui/public/board-room.html +1 -1
  39. package/modules/hall-ui/public/brand-holes.js +121 -0
  40. package/modules/hall-ui/public/collab.html +1 -1
  41. package/modules/hall-ui/public/collab.js +1 -1
  42. package/modules/hall-ui/public/copy-desk.html +1 -1
  43. package/modules/hall-ui/public/deploy.html +6 -1
  44. package/modules/hall-ui/public/deploy.js +22 -8
  45. package/modules/hall-ui/public/diagrams.html +1 -1
  46. package/modules/hall-ui/public/dom-utils.js +20 -0
  47. package/modules/hall-ui/public/drachmae.html +1 -1
  48. package/modules/hall-ui/public/fleet.html +1 -1
  49. package/modules/hall-ui/public/gate.html +1 -1
  50. package/modules/hall-ui/public/goal-inbox.js +1 -1
  51. package/modules/hall-ui/public/goals.html +1 -1
  52. package/modules/hall-ui/public/government.html +1 -1
  53. package/modules/hall-ui/public/idea.html +1 -1
  54. package/modules/hall-ui/public/idea.js +28 -0
  55. package/modules/hall-ui/public/ideas.html +1 -1
  56. package/modules/hall-ui/public/ideas.js +19 -3
  57. package/modules/hall-ui/public/index.html +5 -1
  58. package/modules/hall-ui/public/modules.html +1 -1
  59. package/modules/hall-ui/public/primer.html +1 -1
  60. package/modules/hall-ui/public/profile-nudge.js +21 -4
  61. package/modules/hall-ui/public/profile.html +1 -1
  62. package/modules/hall-ui/public/profile.js +1 -1
  63. package/modules/hall-ui/public/project-settings.html +1 -1
  64. package/modules/hall-ui/public/ranks.html +1 -1
  65. package/modules/hall-ui/public/roadmap.html +1 -1
  66. package/modules/hall-ui/public/roster.html +1 -1
  67. package/modules/hall-ui/public/sessions.html +1 -1
  68. package/modules/hall-ui/public/settings.html +19 -30
  69. package/modules/hall-ui/public/settings.js +24 -177
  70. package/modules/hall-ui/public/settings.states.json +1 -1
  71. package/modules/hall-ui/public/shell.js +4 -2
  72. package/modules/hall-ui/public/studio.css +14 -8
  73. package/modules/hall-ui/public/studio.html +5 -4
  74. package/modules/hall-ui/public/task.html +1 -1
  75. package/modules/hall-ui/public/thinking.css +6 -3
  76. package/modules/hall-ui/public/thinking.html +1 -1
  77. package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
  78. package/modules/hall-ui/public/tweak-editor.css +142 -11
  79. package/modules/hall-ui/public/tweak-editor.html +25 -9
  80. package/modules/hall-ui/public/tweak-editor.js +243 -10
  81. package/modules/hall-ui/public/watch.html +1 -1
  82. package/modules/hall-ui/public/work.html +1 -1
  83. package/modules/hall-ui/records/approval-queue.md +6 -0
  84. package/modules/hall-ui/records/tweak-editor.md +6 -0
  85. package/modules/ideas/ratify.js +36 -8
  86. package/modules/ideas/routes/ratify-goal.js +6 -0
  87. package/modules/lifecycle/db-analytics.js +79 -10
  88. package/modules/lifecycle/db-claims.js +48 -1
  89. package/modules/lifecycle/db-deps-criteria.js +94 -40
  90. package/modules/lifecycle/db-goals.js +4 -1
  91. package/modules/lifecycle/db-grade.js +9 -1
  92. package/modules/lifecycle/db-relevance-flags.js +183 -0
  93. package/modules/lifecycle/db-versions.js +4 -3
  94. package/modules/lifecycle/db.js +19 -0
  95. package/modules/lifecycle/done-when.js +5 -3
  96. package/modules/lifecycle/est-advisory.js +31 -4
  97. package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
  98. package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
  99. package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
  100. package/modules/lifecycle/module.json +2 -1
  101. package/modules/lifecycle/routes/claims.js +8 -1
  102. package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
  103. package/modules/lifecycle/routes/goals.js +1 -1
  104. package/modules/lifecycle/routes/tasks.js +27 -24
  105. package/modules/lifecycle/routes/visuals.js +9 -0
  106. package/modules/lifecycle/ship-preflight.js +7 -11
  107. package/modules/lifecycle/task-visuals.js +109 -5
  108. package/modules/npm-release/module.json +2 -1
  109. package/modules/npm-release/preview/commands.js +81 -0
  110. package/modules/npm-release/preview/divert.js +95 -0
  111. package/modules/npm-release/preview/env.js +78 -0
  112. package/modules/npm-release/preview/proxy.js +107 -0
  113. package/modules/npm-release/preview/runtime.js +83 -0
  114. package/modules/npm-release/preview/supervisor.js +175 -0
  115. package/modules/npm-release/public/work.js +65 -1
  116. package/modules/npm-release/routes/preview.js +133 -0
  117. package/modules/provisioning/app-status.js +83 -0
  118. package/modules/provisioning/module.json +2 -1
  119. package/modules/provisioning/pollers/app-liveness.js +110 -0
  120. package/modules/provisioning/render-lookup.js +68 -0
  121. package/modules/provisioning/render-standup.js +44 -1
  122. package/modules/provisioning/routes/render-standup.js +81 -6
  123. package/modules/provisioning/starter-bundles.js +4 -1
  124. package/modules/public-landing/public/account-private.states.json +14 -0
  125. package/modules/public-landing/public/account.html +513 -0
  126. package/modules/public-landing/public/account.probes.json +37 -0
  127. package/modules/public-landing/public/account.states.json +16 -0
  128. package/modules/public-landing/public/index.html +5 -2
  129. package/modules/public-landing/public/projects.html +661 -18
  130. package/modules/public-landing/public/projects.states.json +8 -1
  131. package/modules/render-deploy/deploys.js +203 -0
  132. package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
  133. package/modules/render-deploy/module.json +25 -0
  134. package/modules/render-deploy/public/deploy.css +10 -0
  135. package/modules/render-deploy/public/deploy.js +249 -0
  136. package/modules/render-deploy/render.js +56 -0
  137. package/modules/render-deploy/routes/act.js +88 -0
  138. package/modules/render-deploy/routes/app.js +65 -0
  139. package/modules/render-deploy/routes/history.js +38 -0
  140. package/modules/specialities/db.js +27 -8
  141. package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
  142. package/modules/specialities/routes/specialities.js +51 -3
  143. package/modules/specialities/skills.js +94 -0
  144. package/modules/specialities/specialities.js +56 -9
  145. package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
  146. package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
  147. package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
  148. package/modules/ui-design/kit/lib.js +3 -1
  149. package/modules/ui-design/kit/serve.js +96 -0
  150. package/package-lock.json +2 -2
  151. package/package.json +1 -1
  152. package/release-notes.json +169 -0
  153. package/scripts/gds/agents-sync.js +13 -3
  154. package/scripts/gds/autobongos-run.js +80 -2
  155. package/scripts/gds/autobongos-service.cmd +12 -0
  156. package/scripts/gds/copy-apply.js +14 -0
  157. package/scripts/gds/dev-box-guard.js +3 -2
  158. package/scripts/gds/fitness.js +9 -0
  159. package/scripts/gds/grade-correlation-audit.js +42 -12
  160. package/scripts/gds/grade-replay.js +1 -1
  161. package/scripts/gds/provision-core-upgrade.js +38 -7
  162. package/scripts/gds/provision-pin-key.js +176 -0
  163. package/scripts/gds/provision-render.js +62 -31
  164. package/scripts/gds/provision-teardown.js +7 -2
  165. package/scripts/gds/provision-units.js +31 -0
  166. package/scripts/gds/provision.js +10 -10
  167. package/scripts/gds/publish-manifest.js +1 -0
  168. package/scripts/gds/render-api.js +19 -0
  169. package/scripts/gds/render-payload.js +39 -65
  170. package/scripts/gds/run-unit-tests.js +10 -0
  171. package/scripts/gds/ship-finish.js +19 -20
  172. package/scripts/gds/smoke-dependencies.sh +37 -8
  173. package/scripts/gds/status.js +12 -4
  174. package/scripts/gds/upgrade.js +2 -2
  175. package/scripts/public-mirror-export.js +7 -1
  176. package/src/bongos/module-scope-map.js +13 -0
  177. package/src/bongos/serve-internal.js +3 -1
  178. package/src/module-api.js +10 -1
  179. package/src/platform-server.js +39 -0
  180. package/tests/account_privacy_flags.mjs +10 -6
  181. package/tests/agents_authoring.mjs +15 -1
  182. package/tests/agents_sync.mjs +106 -5
  183. package/tests/agents_validate.mjs +45 -0
  184. package/tests/api_path_404.mjs +6 -0
  185. package/tests/autobongos_cadence.mjs +7 -0
  186. package/tests/autobongos_loop.mjs +136 -1
  187. package/tests/autonomy_gauge.mjs +62 -0
  188. package/tests/blocker_hall_live_proof.mjs +275 -0
  189. package/tests/claim_gate_ci_unblock.mjs +116 -0
  190. package/tests/claim_gate_rebase.mjs +15 -3
  191. package/tests/collab_page.mjs +10 -0
  192. package/tests/conductor_main_e2e.mjs +97 -0
  193. package/tests/core_262_drop_box_blocked_db.mjs +143 -0
  194. package/tests/core_upgrade_runner.mjs +1 -0
  195. package/tests/dependency_writes_atomic.mjs +191 -0
  196. package/tests/deploy_page_projects.mjs +2 -1
  197. package/tests/effective_visibility_predicate.mjs +15 -0
  198. package/tests/est_advisory.mjs +27 -0
  199. package/tests/goal_map_page.mjs +26 -0
  200. package/tests/goal_task_relevance.mjs +518 -0
  201. package/tests/goal_working_area.mjs +146 -0
  202. package/tests/grade_attempts.mjs +61 -0
  203. package/tests/grade_attribution.mjs +23 -0
  204. package/tests/grade_correlation_audit.mjs +54 -1
  205. package/tests/grader_reader_lens.mjs +55 -2
  206. package/tests/grader_root_outage.mjs +6 -1
  207. package/tests/grader_subagent_tools_arg.mjs +102 -0
  208. package/tests/hall_approval_queue.mjs +54 -2
  209. package/tests/hall_settings_world.mjs +3 -1
  210. package/tests/hall_tweak_editor.mjs +316 -11
  211. package/tests/hub_account_page.mjs +394 -0
  212. package/tests/idea_detail_page.mjs +64 -0
  213. package/tests/idea_goal_ratification.mjs +64 -0
  214. package/tests/idea_spark_hall.mjs +60 -9
  215. package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
  216. package/tests/landing_page.mjs +6 -3
  217. package/tests/module-scope-map.mjs +31 -8
  218. package/tests/module_api.mjs +1 -0
  219. package/tests/module_loader.mjs +1 -1
  220. package/tests/nav_permission_atoms.mjs +1 -1
  221. package/tests/no_phantom_mirror_workflow.mjs +24 -0
  222. package/tests/npm_release_preview_commands.mjs +89 -0
  223. package/tests/npm_release_preview_divert.mjs +164 -0
  224. package/tests/npm_release_preview_env.mjs +100 -0
  225. package/tests/npm_release_preview_proxy.mjs +162 -0
  226. package/tests/npm_release_preview_routes.mjs +208 -0
  227. package/tests/npm_release_preview_supervisor.mjs +203 -0
  228. package/tests/pin_write_key.mjs +383 -0
  229. package/tests/planning_session_skill.mjs +27 -0
  230. package/tests/platform_boot.mjs +71 -0
  231. package/tests/profile_nudge_links.mjs +86 -0
  232. package/tests/profile_ui_cross_project.mjs +1 -1
  233. package/tests/projects_hub.mjs +2 -2
  234. package/tests/projects_hub_app_status.mjs +249 -0
  235. package/tests/projects_hub_app_step.mjs +282 -52
  236. package/tests/projects_hub_render_connect.mjs +295 -0
  237. package/tests/provision_render.mjs +121 -28
  238. package/tests/provision_settings_apply.mjs +62 -0
  239. package/tests/provisioning_app_status.mjs +204 -0
  240. package/tests/provisioning_render_route.mjs +126 -6
  241. package/tests/render_check.mjs +2 -1
  242. package/tests/render_deploy.mjs +495 -0
  243. package/tests/rot_inbox.mjs +8 -0
  244. package/tests/ship_preflight.mjs +61 -8
  245. package/tests/smoke_dependencies_witness.mjs +46 -0
  246. package/tests/speciality_skills.mjs +214 -0
  247. package/tests/studio_room_height.mjs +70 -0
  248. package/tests/task_detail_includes.mjs +10 -0
  249. package/tests/task_visuals_instance_root.mjs +150 -0
  250. package/tests/tweak_batch_apply.mjs +21 -0
  251. package/tests/ui_design_kit.mjs +36 -4
  252. package/tests/upgrade.mjs +1 -1
  253. package/tests/upgrade_persist_pin.mjs +24 -0
@@ -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
+ });
@@ -398,3 +398,65 @@ test('a derived seven-day window is NOT paced against a boundary we invented', (
398
398
  assert.equal(d.derived, true);
399
399
  assert.equal(d.go, true);
400
400
  });
401
+
402
+ // --- a derived mark must be in the CURRENT window (task 1004376) ------------
403
+ //
404
+ // Regression from the 1004178 fix, found on the host the moment the runner was
405
+ // restarted onto it. derivedSince reported the RETIRED reading's resets_at,
406
+ // which on an unattended host is days old — so the caller's cap counted work
407
+ // done days earlier, arrived pre-exhausted, and the runner held forever having
408
+ // correctly derived headroom. Exactly the state 1004178 existed to end.
409
+
410
+ test('a week-old retired reading yields a mark inside the CURRENT window', () => {
411
+ const now = 10_000_000;
412
+ const weekAgoReset = now - 7 * 24 * HOUR; // the retired boundary
413
+ const d = decideCapacity({
414
+ gauge: {
415
+ written_at: weekAgoReset - HOUR,
416
+ five_hour: { used_percentage: 90, resets_at: weekAgoReset, observed_at: weekAgoReset - HOUR },
417
+ },
418
+ now,
419
+ });
420
+ assert.equal(d.derived, true);
421
+ assert.notEqual(d.derivedSince, weekAgoReset, 'the retired boundary is exactly what must NOT come back');
422
+ assert.ok(now - d.derivedSince < 5 * HOUR, 'the mark must sit inside the current five-hour window');
423
+ assert.ok(d.derivedSince <= now, 'and never in the future');
424
+ });
425
+
426
+ test('the advanced mark lands on a whole number of periods from the observed one', () => {
427
+ const now = 10_000_000;
428
+ const reset = now - (3 * 5 * HOUR) - HOUR; // three full windows, plus an hour
429
+ const d = decideCapacity({
430
+ gauge: { written_at: reset - 60, five_hour: { used_percentage: 80, resets_at: reset, observed_at: reset - 60 } },
431
+ now,
432
+ });
433
+ assert.equal(d.derivedSince, reset + 3 * 5 * HOUR, 'stepped forward by whole periods, not guessed');
434
+ });
435
+
436
+ test('derivedSince takes the MOST RECENT boundary when both windows derive', () => {
437
+ const now = 10_000_000;
438
+ const d = decideCapacity({
439
+ gauge: {
440
+ written_at: now - 8 * 24 * HOUR,
441
+ five_hour: { used_percentage: 50, resets_at: now - 6 * HOUR, observed_at: now - 8 * 24 * HOUR },
442
+ seven_day: { used_percentage: 50, resets_at: now - 8 * 24 * HOUR, observed_at: now - 8 * 24 * HOUR },
443
+ },
444
+ now,
445
+ });
446
+ assert.equal(d.derived, true);
447
+ assert.ok(now - d.derivedSince < 5 * HOUR,
448
+ 'the seven-day boundary would make the cap a one-time budget that never refills');
449
+ });
450
+
451
+ test('a reading whose window is still running is unaffected by the advance', () => {
452
+ const now = 10_000_000;
453
+ const d = decideCapacity({
454
+ gauge: {
455
+ written_at: now - 10 * HOUR,
456
+ five_hour: { used_percentage: 92, resets_at: now + HOUR, observed_at: now - 10 * HOUR },
457
+ },
458
+ now,
459
+ });
460
+ assert.equal(d.go, false, 'still unknown — usage only grows inside a live window');
461
+ assert.equal(d.unknown, true);
462
+ });