@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
@@ -261,6 +261,18 @@ async function ratifyCriterionProposal(ideaId, ratifierId, { notesMd = '' } = {}
261
261
  // refused with TEMPLATE_HAS_NO_GOAL_SHAPE, naming the vintage; its author can
262
262
  // resubmit against the current questions, which is what the resubmit path is for.
263
263
  //
264
+ // THE RATIFIER MAY WRITE IT INSTEAD (task 1004226, owner decision 2026-09-29).
265
+ // The board's first ten passed Full Ideas were all base-v1, so the refusal above
266
+ // stranded every one of them. The rule that makes it a refusal still holds:
267
+ // nothing here DERIVES a criterion from a v1 answer. What changed is that the
268
+ // person ratifying may state one in their own words (`success_criterion` on the
269
+ // request). The goal description then carries all the author's answers as
270
+ // written plus a line saying the done-when was written by the ratifier, so a
271
+ // reader never mistakes it for the author's answer; the ratifier's identity is on
272
+ // the audit row. It is ONLY for a template this table does not map: where the
273
+ // author answered the success question, their answer is the criterion and a
274
+ // ratifier's is refused rather than substituted for it.
275
+ //
264
276
  // ADDING A VINTAGE MEANS ADDING AN ENTRY HERE. Nothing falls back — an unmapped
265
277
  // template refuses, rather than guessing at a key that happens to exist.
266
278
  const GOAL_SHAPE_BY_TEMPLATE = Object.freeze({
@@ -286,17 +298,24 @@ const GOAL_SHAPE_BY_TEMPLATE = Object.freeze({
286
298
  // answers in whatever order Postgres chose (templates.js' own header says so).
287
299
  // LABELS come from the declaration too, so a v1 row would render under v1
288
300
  // labels if a v1 shape were ever declared.
289
- function composeGoalFromFullIdea({ ideaId, templateKey, fieldValues } = {}) {
301
+ function composeGoalFromFullIdea({ ideaId, templateKey, fieldValues, ratifierCriterionMd = null } = {}) {
290
302
  const template = templateFor(templateKey);
291
303
  if (!template) return { ok: false, reason: 'unknown_template', template_key: templateKey ?? null };
292
304
  const shape = Object.hasOwn(GOAL_SHAPE_BY_TEMPLATE, templateKey) ? GOAL_SHAPE_BY_TEMPLATE[templateKey] : null;
293
- if (!shape) return { ok: false, reason: 'template_has_no_goal_shape', template_key: templateKey };
305
+ // A blank ratifier criterion is no criterion: it must not turn a refusal into
306
+ // a goal whose only gate is whitespace.
307
+ const ratifierMd = typeof ratifierCriterionMd === 'string' && ratifierCriterionMd.trim() !== ''
308
+ ? ratifierCriterionMd.trim() : null;
309
+ if (shape && ratifierMd) {
310
+ return { ok: false, reason: 'template_supplies_done_when', template_key: templateKey, field: shape.doneWhenField };
311
+ }
312
+ if (!shape && !ratifierMd) return { ok: false, reason: 'template_has_no_goal_shape', template_key: templateKey };
294
313
  if (!fieldValues || typeof fieldValues !== 'object' || Array.isArray(fieldValues)) {
295
314
  return { ok: false, reason: 'field_values_missing' };
296
315
  }
297
316
 
298
- const doneWhen = fieldValues[shape.doneWhenField];
299
- if (typeof doneWhen !== 'string' || doneWhen.trim() === '') {
317
+ const doneWhen = shape ? fieldValues[shape.doneWhenField] : ratifierMd;
318
+ if (shape && (typeof doneWhen !== 'string' || doneWhen.trim() === '')) {
300
319
  // A Full Idea cannot normally reach here with a blank required field —
301
320
  // validateFieldValues refuses that at capture. It is checked anyway because
302
321
  // the alternative is a goal whose only criterion is an empty string, which
@@ -322,11 +341,17 @@ function composeGoalFromFullIdea({ ideaId, templateKey, fieldValues } = {}) {
322
341
  // opening the goal learns, without asking anyone, that its scope is somebody's
323
342
  // thought and where to go and read it whole.
324
343
  const origin = `> Ratified from Full Idea [#${ideaId}](/idea/${ideaId}) — its five answers, as written.`;
344
+ // Said in the prose, because the criterion is not one of the answers above and
345
+ // a reader must not take it for the author's (task 1004226).
346
+ const byRatifier = shape ? [] : [
347
+ `> Its done-when criterion was written by the ratifier at ratification: this idea's questions (${templateKey}) did not ask what success looks like.`,
348
+ ];
325
349
  return {
326
350
  ok: true,
327
- description: [origin, ...sections].join('\n\n'),
351
+ description: [origin, ...byRatifier, ...sections].join('\n\n'),
328
352
  criterionMd: doneWhen.trim(),
329
- doneWhenField: shape.doneWhenField,
353
+ doneWhenField: shape ? shape.doneWhenField : null,
354
+ doneWhenFrom: shape ? shape.doneWhenField : 'ratifier',
330
355
  };
331
356
  }
332
357
 
@@ -353,7 +378,7 @@ function composeGoalFromFullIdea({ ideaId, templateKey, fieldValues } = {}) {
353
378
  // recommendation a decision, which is the one thing it is not.
354
379
  async function ratifyFullIdeaAsGoal(
355
380
  ideaId, ratifierId,
356
- { targetVersion, scopeModules = [], admitReason = null, actorRank = null, notesMd = '' } = {}
381
+ { targetVersion, scopeModules = [], admitReason = null, actorRank = null, notesMd = '', ratifierCriterionMd = null } = {}
357
382
  ) {
358
383
  const id = Number(ideaId);
359
384
  if (!Number.isFinite(id)) throw { code: 'BAD_ID' };
@@ -409,6 +434,7 @@ async function ratifyFullIdeaAsGoal(
409
434
  // c. Compose. Pure, and its refusals are the caller's 400s.
410
435
  const composed = composeGoalFromFullIdea({
411
436
  ideaId: idea.id, templateKey: idea.template_key, fieldValues: idea.field_values,
437
+ ratifierCriterionMd,
412
438
  });
413
439
  if (!composed.ok) throw { code: 'CANNOT_COMPOSE_GOAL', reason: composed.reason, ...composed };
414
440
 
@@ -476,7 +502,9 @@ async function ratifyFullIdeaAsGoal(
476
502
  // Reported back so a caller can see what the author had recommended and
477
503
  // what the board did with it, without a second read. NULL stays NULL.
478
504
  recommendation_at_filing: idea.recommendation ?? null,
479
- done_when_from: composed.doneWhenField,
505
+ // 'ratifier' when the person ratifying wrote it (task 1004226), else the
506
+ // template field the author answered.
507
+ done_when_from: composed.doneWhenFrom,
480
508
  proposer_id: idea.captured_by,
481
509
  };
482
510
  } catch (err) {
@@ -110,6 +110,11 @@ module.exports = function buildRatifyGoalRouter() {
110
110
  // 'ok' is not a reason.
111
111
  admit_reason: { type: 'string', minLength: 8, maxLength: 500 },
112
112
  notes_md: { type: 'string', maxLength: LIMITS.NOTES },
113
+ // task 1004226: the success criterion, in the RATIFIER's words, for a
114
+ // Full Idea whose questions never asked what success looks like
115
+ // (base-v1). Refused for a template that did ask, so the author's own
116
+ // answer is never replaced. Absent or blank means none was written.
117
+ success_criterion: { type: 'string', maxLength: 2000 },
113
118
  })) return;
114
119
  const id = parseId(req, res, { code: 'bad_id' });
115
120
  if (id === null) return;
@@ -122,6 +127,7 @@ module.exports = function buildRatifyGoalRouter() {
122
127
  // (ADR 0016) — the port denies when it is absent.
123
128
  actorRank: req.builder.rank,
124
129
  notesMd: typeof req.body.notes_md === 'string' ? req.body.notes_md : '',
130
+ ratifierCriterionMd: typeof req.body.success_criterion === 'string' ? req.body.success_criterion : null,
125
131
  });
126
132
  res.status(201).json({ ok: true, ...result });
127
133
  } catch (err) {
@@ -579,18 +579,60 @@ async function ecosystemAnalytics(days = 90, kind = null) {
579
579
  // writes actually queryable through the API, not just via psql
580
580
  // (ADR 0033 §1 / F4 finding).
581
581
 
582
- async function logGradeAttempt({ taskId, builderId, passed, score }, queryFn) {
582
+ // workerVerdicts — one grade's per-worker signals slimmed to what the
583
+ // correlation audit reads (task 1004292): kind, verdict, the error CODE and the
584
+ // severities of the worker's issues. NO prose: no summary, file or stderr, which
585
+ // task_grades keeps for the latest grade only. Pure; returns null when there is
586
+ // nothing to keep (a non-panel grade), so the column stays NULL.
587
+ const VERDICT_WORKER_CAP = 12; // workers kept per attempt (a panel is ~4-6 plus pseudo-judges)
588
+ const VERDICT_SEVERITY_CAP = 50; // severities kept per worker
589
+ const VERDICT_KIND_MAX = 40;
590
+ const VERDICT_VERDICT_MAX = 20;
591
+ const VERDICT_ERROR_MAX = 80;
592
+ const VERDICT_SEVERITY_MAX = 20;
593
+ const VERDICT_ROUNDS_MAX = 50; // rounds served per task (the most recent)
594
+ function workerVerdicts(workers) {
595
+ if (!Array.isArray(workers)) return null;
596
+ const out = [];
597
+ for (const w of workers) {
598
+ if (!w || typeof w !== 'object' || typeof w.kind !== 'string') continue;
599
+ out.push({
600
+ kind: w.kind.slice(0, VERDICT_KIND_MAX),
601
+ verdict: typeof w.verdict === 'string' ? w.verdict.slice(0, VERDICT_VERDICT_MAX) : null,
602
+ error: w.error == null ? null : String(w.error).slice(0, VERDICT_ERROR_MAX),
603
+ severities: (Array.isArray(w.issues) ? w.issues : [])
604
+ .map((i) => (i && typeof i.severity === 'string' ? i.severity.slice(0, VERDICT_SEVERITY_MAX) : null))
605
+ .filter(Boolean)
606
+ .slice(0, VERDICT_SEVERITY_CAP),
607
+ });
608
+ if (out.length >= VERDICT_WORKER_CAP) break;
609
+ }
610
+ return out.length ? out : null;
611
+ }
612
+
613
+ async function logGradeAttempt({ taskId, builderId, passed, score, workers }, queryFn) {
583
614
  const q = queryFn || ((text, params) => pool.query(text, params));
615
+ const base = [
616
+ Number(taskId),
617
+ builderId == null ? null : Number(builderId),
618
+ passed === true,
619
+ score == null ? null : Number(score),
620
+ ];
621
+ const verdicts = workerVerdicts(workers);
622
+ // task 1004292: the per-worker verdicts ride the same row, but a failure to
623
+ // write them (the column not yet migrated, a serialisation error) must never
624
+ // cost the row itself, so the plain insert is the fallback.
625
+ if (verdicts) {
626
+ try {
627
+ await q(
628
+ `INSERT INTO grade_attempts (task_id, builder_id, passed, score, worker_verdicts) VALUES ($1, $2, $3, $4, $5::jsonb)`,
629
+ [...base, JSON.stringify(verdicts)]
630
+ );
631
+ return true;
632
+ } catch (_err) { /* fall through to the plain insert below, which logs if it fails too */ }
633
+ }
584
634
  try {
585
- await q(
586
- `INSERT INTO grade_attempts (task_id, builder_id, passed, score) VALUES ($1, $2, $3, $4)`,
587
- [
588
- Number(taskId),
589
- builderId == null ? null : Number(builderId),
590
- passed === true,
591
- score == null ? null : Number(score),
592
- ]
593
- );
635
+ await q(`INSERT INTO grade_attempts (task_id, builder_id, passed, score) VALUES ($1, $2, $3, $4)`, base);
594
636
  return true;
595
637
  } catch (err) {
596
638
  console.error('[gds] logGradeAttempt (non-blocking):', err && err.message ? err.message : err);
@@ -598,6 +640,30 @@ async function logGradeAttempt({ taskId, builderId, passed, score }, queryFn) {
598
640
  }
599
641
  }
600
642
 
643
+ // gradeAttemptVerdicts — every recorded round of a task that kept its per-worker
644
+ // verdicts, oldest first: [{ attempted_at, passed, workers }] (task 1004292).
645
+ // Rounds made before the column existed are simply absent. Never throws: a read
646
+ // failure (column not migrated yet) is an empty list, and the audit falls back
647
+ // to the task's final grade.
648
+ async function gradeAttemptVerdicts(taskId, queryFn) {
649
+ const q = queryFn || ((text, params) => pool.query(text, params));
650
+ try {
651
+ const { rows } = await q(
652
+ `SELECT attempted_at, passed, workers FROM (
653
+ SELECT id, attempted_at, passed, worker_verdicts AS workers
654
+ FROM grade_attempts
655
+ WHERE task_id = $1 AND worker_verdicts IS NOT NULL
656
+ ORDER BY attempted_at DESC, id DESC
657
+ LIMIT $2) recent
658
+ ORDER BY attempted_at ASC, id ASC`,
659
+ [Number(taskId), VERDICT_ROUNDS_MAX]
660
+ );
661
+ return rows;
662
+ } catch (_err) {
663
+ return []; // best-effort read: an unmigrated column means "no earlier rounds", not an error
664
+ }
665
+ }
666
+
601
667
  // countGradeAttempts — how many times a task has been graded (re-grade thrash).
602
668
 
603
669
  async function countGradeAttempts(taskId, queryFn) {
@@ -777,6 +843,9 @@ module.exports = {
777
843
  gradeByBuilder,
778
844
  gradeTrendByDay,
779
845
  logGradeAttempt,
846
+ gradeAttemptVerdicts,
847
+ workerVerdicts,
848
+ VERDICT_LIMITS: { workers: VERDICT_WORKER_CAP, severities: VERDICT_SEVERITY_CAP, rounds: VERDICT_ROUNDS_MAX },
780
849
  mergeLeaseStatus,
781
850
  recentGradesForBuilder,
782
851
  recentShippedTasks,
@@ -22,6 +22,11 @@ const { touchesOverlap } = api.pathMatch;
22
22
  // un-testable except by counting.
23
23
  const { CLAIM_ANY, CLAIM_NEWCOMER } = require('./claim-eligibility.js');
24
24
  const { goalMembershipFailures } = require('./claim-feed.js');
25
+ // ADR 0101 Part 1 (task 1001691) — the relevance/anti-hack judge's claim-time
26
+ // friction gate. Same extraction shape as goalMembershipFailures: one decision,
27
+ // called from all three claim surfaces below, so a flagged task can't be
28
+ // claimed via the batch path as a bypass of the single-claim gate.
29
+ const { relevanceFrictionFailures } = require('./db-relevance-flags.js');
25
30
  const { unsatisfiedDeps } = require('./db-deps-criteria.js');
26
31
  // depRefusalRow: the ONE shape a DEPS_NOT_SHIPPED refusal carries. All three
27
32
  // claim paths below use it — hand-building the object here is what made task
@@ -79,6 +84,21 @@ function claimUniqueViolationError(err) {
79
84
  return { code: 'ALREADY_CLAIMED' };
80
85
  }
81
86
 
87
+ // isRebaseGateExempt — the one task the owed-rebase gate below never refuses
88
+ // (task 1004370). When main's CI goes red for a reason outside every branch (an
89
+ // npm advisory tripping the audit gate, 2026-09-29), no confirmed task can land,
90
+ // so the gate shuts the queue of every builder holding one — including the claim
91
+ // on the fix that would let them land. Releasing is no exit either: a confirmed
92
+ // task has no open claim to release. So a P1 kind=infra task, the shape a
93
+ // CI-unblock is filed in, passes the gate. Everything else stays gated, and the
94
+ // owed rebases are still reported everywhere they were. Filing one takes
95
+ // task.create, which is Metic-floored (catalog.js), so the door is as narrow as
96
+ // the people who could already file the fix. A missing row is not exempt, so a
97
+ // gated builder still meets REBASE_REQUIRED first, in the order it always was.
98
+ function isRebaseGateExempt(task) {
99
+ return Boolean(task) && task.kind === 'infra' && Number(task.priority) === 1;
100
+ }
101
+
82
102
  async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSessionId, enforceOverlap = false }) {
83
103
  const heldPermissions = await claimPermissionsFor(builderId); // BV1.R106 — before the tx opens
84
104
  const client = await pool.connect();
@@ -112,7 +132,9 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
112
132
  // The GATING subset, not the surfacing one: a strand the builder cannot clear
113
133
  // stops blocking after a grace, while still being reported (task 1003294).
114
134
  const owedRebases = await gatingRebaseTasksForBuilder(builderId, client);
115
- if (owedRebases.length > 0) {
135
+ if (owedRebases.length > 0 && !isRebaseGateExempt(
136
+ (await client.query(`SELECT kind, priority FROM tasks WHERE id = $1`, [taskId])).rows[0],
137
+ )) {
116
138
  throw {
117
139
  code: 'REBASE_REQUIRED',
118
140
  rebaseTasks: owedRebases.map((t) => ({ id: t.id, title: t.title, reason: t.needs_rebase_reason })),
@@ -213,6 +235,14 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
213
235
  const goalRefusals = await goalMembershipFailures({ client, tasks: [task], builderId, builderRank });
214
236
  if (goalRefusals.length > 0) throw goalRefusals[0].refusal;
215
237
 
238
+ // task 1001691 / ADR 0101 Part 1: the relevance judge's "raise friction"
239
+ // gate — a high-confidence or repeat scope-gaming flag holds the claim
240
+ // open until a Metic reviews it (never permanently; either decision
241
+ // releases it). Same ladder position as goal membership: after rank +
242
+ // membership, before deps.
243
+ const relevanceRefusals = await relevanceFrictionFailures({ client, tasks: [task] });
244
+ if (relevanceRefusals.length > 0) throw relevanceRefusals[0].refusal;
245
+
216
246
  // The claim gate (ADR 0015, generalized in task 1516 / ADR 0086 §5): a task
217
247
  // can't be claimed while any dependency is un-done — its OWN edges plus those
218
248
  // inherited from its goal/criteria, where "done" = task shipped | criterion
@@ -593,6 +623,15 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
593
623
  failures.push({ task_id: f.task_id, reason: code, ...rest });
594
624
  }
595
625
 
626
+ // task 1001691 / ADR 0101 Part 1: the SAME extracted relevance-friction
627
+ // gate as claimTask — kept in sync for the same reason goal membership is
628
+ // (see claim-feed.js's header): the batch path owns the repo's only other
629
+ // INSERT INTO claims, so an inline-only gate would be bypassable via batch.
630
+ for (const f of await relevanceFrictionFailures({ client, tasks: taskRows })) {
631
+ const { code, ...rest } = f.refusal;
632
+ failures.push({ task_id: f.task_id, reason: code, ...rest });
633
+ }
634
+
596
635
  // Per-task dependency check (ADR 0015, generalized task 1516). An EXTERNAL dep
597
636
  // (not in this batch) must already be done; a batch-internal dep isn't done yet
598
637
  // at claim time but doesn't block — the batch is worked serially. "Internal"
@@ -939,6 +978,13 @@ async function validateClaimBatch({ taskIds, builderId }) {
939
978
  push(f.task_id, { reason: code, ...rest });
940
979
  }
941
980
 
981
+ // task 1001691 / ADR 0101 Part 1: relevance-friction, kept in sync with
982
+ // claimTask + claimTasksBatch (see db-relevance-flags.js).
983
+ for (const f of await relevanceFrictionFailures({ client: pool, tasks: taskRows })) {
984
+ const { code, ...rest } = f.refusal;
985
+ push(f.task_id, { reason: code, ...rest });
986
+ }
987
+
942
988
  // Dependencies — external to the batch only (a batch-internal dep is fine;
943
989
  // the batch is worked serially, same rule as claimTasksBatch).
944
990
  const batchIdSet = new Set(uniqIds);
@@ -1006,6 +1052,7 @@ module.exports = {
1006
1052
  claimTask,
1007
1053
  claimTasksBatch,
1008
1054
  claimUniqueViolationError,
1055
+ isRebaseGateExempt,
1009
1056
  releaseClaimsBatch,
1010
1057
  validateClaimBatch,
1011
1058
  };
@@ -147,28 +147,72 @@ async function findDependencyCycle(taskId, depTaskId, executor = pool) {
147
147
  }
148
148
 
149
149
 
150
- async function addTaskDependency(taskId, depTaskId) {
151
- if (taskId === depTaskId) {
152
- throw { code: 'SELF_DEP' };
153
- }
154
- // Confirm both tasks exist
155
- const { rows: existing } = await pool.query(
156
- `SELECT id FROM tasks WHERE id = ANY($1::bigint[])`,
157
- [[taskId, depTaskId]]
158
- );
159
- if (existing.length !== 2) {
160
- throw { code: 'TASK_NOT_FOUND' };
161
- }
162
- const cyclePath = await findDependencyCycle(taskId, depTaskId);
163
- if (cyclePath) {
164
- throw { code: 'CYCLE', path: cyclePath };
165
- }
166
- await pool.query(
167
- `INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
168
- VALUES ('task', $1, 'task', $2)
169
- ON CONFLICT DO NOTHING`,
170
- [taskId, depTaskId]
171
- );
150
+ // ---- every dependency-graph WRITE is one locked transaction (task 1004191) ----
151
+ // The edge writers used to run their existence check, cycle check and INSERT as
152
+ // separate statements on the bare pool. Two findings from the task 1000379 audit
153
+ // followed from that, both verified real on 2026-09-29:
154
+ // - partial writes (63/81): POST /tasks/:id/dependencies looped addTaskDependency
155
+ // per id, so a cycle on the 3rd id left the 1st and 2nd committed;
156
+ // - TOCTOU (75): the cycle walk and the INSERT ran on different connections, so
157
+ // concurrent A->B and B->A could both pass the walk and commit a cycle.
158
+ // Now each writer opens ONE transaction, takes ONE advisory lock that serializes
159
+ // every graph write (the graph is small and written rarely, by Metic+ only), and
160
+ // runs existence + cycle + insert on that client. Under READ COMMITTED each
161
+ // statement after the lock sees every write that committed before the lock was
162
+ // granted, so the walk always sees the other writer's edge.
163
+ const DEP_GRAPH_LOCK = 'dependencies/graph-write';
164
+
165
+ async function lockDependencyGraph(client) {
166
+ await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [DEP_GRAPH_LOCK]);
167
+ }
168
+
169
+ // Coerce + validate a list of ids. Throws BAD_ID (a 400) instead of letting a
170
+ // non-numeric id reach Postgres as a bigint cast error (a 500 — finding 64).
171
+ function toDepIds(list) {
172
+ const ids = list.map(Number);
173
+ if (ids.some((n) => !Number.isSafeInteger(n) || n <= 0)) throw { code: 'BAD_ID' };
174
+ return [...new Set(ids)];
175
+ }
176
+
177
+ // There is no FK on dependencies (the target table varies by kind), so an unknown
178
+ // id would be written as an orphan edge that blocks its task forever (fail-closed).
179
+ // Refuse it up front and name what was missing.
180
+ async function assertTasksExist(client, ids) {
181
+ const { rows } = await client.query(`SELECT id FROM tasks WHERE id = ANY($1::bigint[])`, [ids]);
182
+ const found = new Set(rows.map((r) => Number(r.id)));
183
+ const missing = ids.filter((id) => !found.has(id));
184
+ if (missing.length > 0) throw { code: 'TASK_NOT_FOUND', missing };
185
+ }
186
+
187
+ const txOpts = (opts) => (opts && opts.pool ? { pool: opts.pool } : undefined);
188
+
189
+ // Add task->task edges from taskId to every id in depTaskIds, all or nothing.
190
+ // Throws BAD_ID, SELF_DEP, TASK_NOT_FOUND ({missing}) or CYCLE ({path}); on any
191
+ // throw nothing is written. Returns the unique dep ids. `opts.pool` is a test seam.
192
+ async function addTaskDependencies(taskId, depTaskIds, opts) {
193
+ const [id] = toDepIds([taskId]);
194
+ const deps = toDepIds(depTaskIds);
195
+ if (deps.includes(id)) throw { code: 'SELF_DEP' };
196
+ return withTx(async (client) => {
197
+ await lockDependencyGraph(client);
198
+ await assertTasksExist(client, [id, ...deps]);
199
+ for (const dep of deps) {
200
+ const cyclePath = await findDependencyCycle(id, dep, client);
201
+ if (cyclePath) throw { code: 'CYCLE', path: cyclePath };
202
+ await client.query(
203
+ `INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
204
+ VALUES ('task', $1, 'task', $2)
205
+ ON CONFLICT DO NOTHING`,
206
+ [id, dep]
207
+ );
208
+ }
209
+ return deps;
210
+ }, txOpts(opts));
211
+ }
212
+
213
+ // Single-edge form (routes/goals.js). Same transaction; historical return shape.
214
+ async function addTaskDependency(taskId, depTaskId, opts) {
215
+ await addTaskDependencies(taskId, [depTaskId], opts);
172
216
  return { task_id: taskId, depends_on_task_id: depTaskId };
173
217
  }
174
218
 
@@ -187,34 +231,39 @@ async function removeTaskDependency(taskId, depTaskId) {
187
231
  // Validates no cycles among the new set in one transaction. Order of the
188
232
  // list doesn't matter.
189
233
 
190
- async function setTaskDependencies(taskId, depTaskIds) {
191
- const unique = [...new Set(depTaskIds.map(Number))];
192
- if (unique.includes(Number(taskId))) {
234
+ async function setTaskDependencies(taskId, depTaskIds, opts) {
235
+ const [id] = toDepIds([taskId]);
236
+ const unique = toDepIds(depTaskIds);
237
+ if (unique.includes(id)) {
193
238
  throw { code: 'SELF_DEP' };
194
239
  }
195
240
  return withTx(async (client) => {
241
+ // task 1004191: serialize against every other graph write (finding 75), and
242
+ // 404 an unknown task or dep id instead of writing orphan edges (finding 64).
243
+ await lockDependencyGraph(client);
244
+ await assertTasksExist(client, [id, ...unique]);
196
245
  // Replace only this task's task->task edges; leave any task->goal/criterion
197
246
  // edges (created via the generic /dependencies endpoint) untouched.
198
247
  await client.query(
199
248
  `DELETE FROM dependencies WHERE from_kind = 'task' AND from_id = $1 AND to_kind = 'task'`,
200
- [taskId]
249
+ [id]
201
250
  );
202
251
  for (const dep of unique) {
203
252
  // Re-check cycle for each insert (using the same txn connection so the
204
253
  // previous inserts in this txn are visible). Reuses findDependencyCycle
205
254
  // with the txn client as executor — task 1087, C3.
206
- const cyclePath = await findDependencyCycle(taskId, dep, client);
255
+ const cyclePath = await findDependencyCycle(id, dep, client);
207
256
  if (cyclePath) {
208
257
  throw { code: 'CYCLE', path: cyclePath };
209
258
  }
210
259
  await client.query(
211
260
  `INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
212
261
  VALUES ('task', $1, 'task', $2)`,
213
- [taskId, dep]
262
+ [id, dep]
214
263
  );
215
264
  }
216
265
  return unique;
217
- });
266
+ }, txOpts(opts));
218
267
  }
219
268
 
220
269
  // ---------------------------------------------------------------------------
@@ -240,19 +289,23 @@ async function dependencyNodeExists(kind, id, executor = pool) {
240
289
  // Add a polymorphic edge "from is blocked until to is done". Throws BAD_KIND,
241
290
  // SELF_DEP, NODE_NOT_FOUND, or CYCLE (with the offending kind:id path).
242
291
 
243
- async function addDependency(fromKind, fromId, toKind, toId) {
292
+ async function addDependency(fromKind, fromId, toKind, toId, opts) {
244
293
  if (!DEP_KINDS.includes(fromKind) || !DEP_KINDS.includes(toKind)) throw { code: 'BAD_KIND' };
245
294
  if (fromKind === toKind && Number(fromId) === Number(toId)) throw { code: 'SELF_DEP' };
246
- if (!(await dependencyNodeExists(fromKind, fromId)) || !(await dependencyNodeExists(toKind, toId))) {
247
- throw { code: 'NODE_NOT_FOUND' };
248
- }
249
- const cyclePath = await findCycle(fromKind, fromId, toKind, toId);
250
- if (cyclePath) throw { code: 'CYCLE', path: cyclePath };
251
- await pool.query(
252
- `INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
253
- VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
254
- [fromKind, fromId, toKind, toId]
255
- );
295
+ // task 1004191 (finding 75): one locked transaction, same as the task writers.
296
+ await withTx(async (client) => {
297
+ await lockDependencyGraph(client);
298
+ if (!(await dependencyNodeExists(fromKind, fromId, client)) || !(await dependencyNodeExists(toKind, toId, client))) {
299
+ throw { code: 'NODE_NOT_FOUND' };
300
+ }
301
+ const cyclePath = await findCycle(fromKind, fromId, toKind, toId, client);
302
+ if (cyclePath) throw { code: 'CYCLE', path: cyclePath };
303
+ await client.query(
304
+ `INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
305
+ VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
306
+ [fromKind, fromId, toKind, toId]
307
+ );
308
+ }, txOpts(opts));
256
309
  return { from_kind: fromKind, from_id: Number(fromId), to_kind: toKind, to_id: Number(toId) };
257
310
  }
258
311
 
@@ -488,6 +541,7 @@ async function unsatisfiedDeps(taskId, client = pool) {
488
541
  module.exports = {
489
542
  addDependency,
490
543
  addTaskCriterion,
544
+ addTaskDependencies,
491
545
  addTaskDependency,
492
546
  attachTaskDepSummaries,
493
547
  countTasksWithoutCriterionLink,
@@ -43,8 +43,11 @@ const { autoCloseVersionsForGoals } = require('./version-autoclose.js');
43
43
  // the close count's exemption, and the hall, which should not render the one goal
44
44
  // nobody chose as though a person had scoped it. Additive: every consumer selects
45
45
  // this list, so nothing had to be found and changed.
46
+ // `working_area` (task 1004057, migration core_260) is the ADR 0264 area number
47
+ // as data, NULL for every other goal. It is NOT sort_order and NOT the id — both
48
+ // look like it on a payload and neither is.
46
49
  const GOAL_COLS =
47
- 'id, version_id, title, subtitle, description, scope_modules, status, visibility, accepting_requests, created_by, succeeded_by_goal_id, sort_order, category_id, is_maintenance, created_at, updated_at';
50
+ 'id, version_id, title, subtitle, description, scope_modules, status, visibility, accepting_requests, created_by, succeeded_by_goal_id, sort_order, category_id, is_maintenance, working_area, created_at, updated_at';
48
51
 
49
52
  // Shared column list for a goal_membership_requests row (task 1951 / ADR 0112) —
50
53
  // the invite / join-request queue behind private goals. Every helper below shapes
@@ -260,7 +260,15 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
260
260
  // re-grade thrash is countable (task_grades is upsert-unique). Post-commit +
261
261
  // best-effort: logGradeAttempt uses a pooled connection and swallows its own
262
262
  // errors, so it can never roll back or block the grade that just committed.
263
- await logGradeAttempt({ taskId, builderId, passed: gradeResult.passed, score: gradeResult.score });
263
+ // task 1004292: the per-worker verdicts (kind, verdict, error code, issue
264
+ // severities; no prose) ride the same row so an earlier round survives the
265
+ // re-grade that overwrites task_grades. Best-effort inside logGradeAttempt.
266
+ // `deps.attemptQuery` is the test-only seam (NODE_ENV==='test'), like deps.pool.
267
+ const attemptQuery = (deps.attemptQuery && process.env.NODE_ENV === 'test') ? deps.attemptQuery : undefined;
268
+ await logGradeAttempt({
269
+ taskId, builderId, passed: gradeResult.passed, score: gradeResult.score,
270
+ workers: gradeResult.signals && gradeResult.signals.workers,
271
+ }, attemptQuery);
264
272
 
265
273
  return result;
266
274
  }