@bongos/core 1.19.1033 → 1.19.1034

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 (100) hide show
  1. package/.bongos-core.json +186 -86
  2. package/.claude/skills/builder-start/SKILL.md +1 -1
  3. package/.claude/skills/grade-recover/SKILL.md +2 -1
  4. package/.claude/skills/grader-health/SKILL.md +4 -0
  5. package/.claude/skills/merge-mode/SKILL.md +1 -1
  6. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
  7. package/docs/adr/README.md +1 -1
  8. package/docs/api/openapi.json +1 -1
  9. package/docs/architecture.md +3 -2
  10. package/docs/canonical-permissions.md +2 -2
  11. package/docs/copy-inventory.md +34 -30
  12. package/docs/copy-registry.json +69 -33
  13. package/docs/module-api-changelog.md +3 -1
  14. package/docs/recipes/local-dev.md +45 -3
  15. package/docs/recipes/packaging-the-core.md +1 -1
  16. package/modules/autonomy/cadence.js +52 -0
  17. package/modules/government/protected-surfaces.json +1 -0
  18. package/modules/grading/grade-engine.js +267 -0
  19. package/modules/grading/grader-subagent.js +4 -3
  20. package/modules/grading/grader-workers/index.js +67 -11
  21. package/modules/grading/grader-workers/worker-verdict.js +32 -2
  22. package/modules/grading/grader.js +109 -88
  23. package/modules/hall-ui/public/deploy.js +35 -0
  24. package/modules/hall-ui/public/project-settings.css +7 -0
  25. package/modules/lifecycle/routes/tasks.js +19 -4
  26. package/modules/provisioning/control-plane-guard.js +14 -13
  27. package/modules/provisioning/core-upgrade.js +30 -17
  28. package/modules/provisioning/public-refusal.js +44 -0
  29. package/modules/provisioning/routes/disconnect.js +2 -1
  30. package/modules/provisioning/routes/env-manifest.js +5 -4
  31. package/modules/provisioning/routes/provisioning.js +16 -15
  32. package/modules/provisioning/routes/repo-private.js +2 -1
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/release-notes.json +70 -0
  36. package/scripts/gds/autobongos-run.js +10 -2
  37. package/scripts/gds/backfill-cost.js +20 -9
  38. package/scripts/gds/claim.js +2 -2
  39. package/scripts/gds/credential-presence.js +281 -0
  40. package/scripts/gds/deploy-main-audit-gate.sh +11 -4
  41. package/scripts/gds/dev-lib.js +212 -5
  42. package/scripts/gds/dev.js +102 -50
  43. package/scripts/gds/doctor.js +3 -2
  44. package/scripts/gds/grade-correlation-audit.js +280 -0
  45. package/scripts/gds/grade-replay.js +237 -0
  46. package/scripts/gds/hooks/pre-push.js +7 -3
  47. package/scripts/gds/main-audit.js +19 -10
  48. package/scripts/gds/memory-map.js +3 -1
  49. package/scripts/gds/onboarding-config.js +3 -2
  50. package/scripts/gds/plain-cards.js +4 -0
  51. package/scripts/gds/preflight.js +12 -4
  52. package/scripts/gds/provision-core-upgrade.js +155 -4
  53. package/scripts/gds/provision-repo-auth.js +15 -0
  54. package/scripts/gds/provision.js +1 -1
  55. package/scripts/gds/publish-credential-check.js +3 -0
  56. package/scripts/gds/push-main.js +14 -3
  57. package/scripts/gds/push-path-brief.js +3 -2
  58. package/scripts/gds/run-routine.js +18 -5
  59. package/scripts/gds/run-unit-tests.js +14 -3
  60. package/scripts/gds/session-cost.js +12 -3
  61. package/scripts/gds/ship-deploy-target.js +11 -6
  62. package/scripts/gds/ship-grade-steps.js +33 -0
  63. package/scripts/gds/ship-land.js +157 -11
  64. package/scripts/gds/ship-merge.js +3 -2
  65. package/scripts/gds/ship-preflight-steps.js +30 -8
  66. package/scripts/gds/ship.js +1 -1
  67. package/scripts/gds/upgrade.js +3 -3
  68. package/scripts/hall-preview/README.md +20 -3
  69. package/scripts/hall-preview/live-identity.js +1 -1
  70. package/scripts/hall-preview/refresh-fixtures.js +71 -11
  71. package/scripts/hall-preview/scrub-identity.js +296 -0
  72. package/src/bongos/routes/security.js +3 -3
  73. package/src/build-info.js +4 -2
  74. package/src/module-api.js +1 -1
  75. package/tests/adr_0089_scope.mjs +70 -0
  76. package/tests/autobongos_cadence.mjs +74 -0
  77. package/tests/consumer_layout_boot.mjs +363 -31
  78. package/tests/core_upgrade_door.mjs +40 -6
  79. package/tests/core_upgrade_runner.mjs +240 -4
  80. package/tests/credential_presence.mjs +153 -0
  81. package/tests/deploy_audit_gate.mjs +5 -2
  82. package/tests/deploy_page_projects.mjs +63 -2
  83. package/tests/dev_lib.mjs +2 -1
  84. package/tests/dev_windows.mjs +324 -0
  85. package/tests/env_knobs_neutral_prefix.mjs +203 -0
  86. package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
  87. package/tests/government_protected_surfaces.mjs +10 -0
  88. package/tests/grade_correlation_audit.mjs +159 -0
  89. package/tests/grade_engine.mjs +249 -0
  90. package/tests/grade_engine_replay.mjs +99 -0
  91. package/tests/grader_prompt_window_bound.mjs +132 -0
  92. package/tests/grader_unbacked_score.mjs +194 -0
  93. package/tests/hall_preview_scrub.mjs +214 -0
  94. package/tests/integration_parked_ratchet.mjs +24 -1
  95. package/tests/provisioning_public_refusal.mjs +195 -0
  96. package/tests/publish_status_branch.mjs +128 -0
  97. package/tests/ship_land_red_pr.mjs +236 -0
  98. package/tests/ship_preflight.mjs +67 -1
  99. package/tests/start_env_guard.mjs +16 -0
  100. package/tests/upgrade.mjs +20 -1
@@ -1308,7 +1308,7 @@ async function cmdRunIntents(deps) {
1308
1308
  await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
1309
1309
  }
1310
1310
  errors++;
1311
- } else { await provisioning.resolveIntent(db, intent.id, 'done', null); ok++; }
1311
+ } else { await provisioning.resolveIntent(db, intent.id, 'done', (result && result.doneNote) || null); ok++; } // done WITH a note: it landed, and this is what it could not finish (task 1004065)
1312
1312
  } catch (e) {
1313
1313
  await provisioning.resolveIntent(db, intent.id, 'error', e.message).catch(() => {});
1314
1314
  // Surface the failure on the instance too, so a stuck instance is visible.
@@ -16,6 +16,9 @@
16
16
  // vetoes a git-history-proven land). This tells you whether you are relying on that
17
17
  // fallback instead of a real deploy gate.
18
18
  //
19
+ // For WHETHER each credential is set at all (names only, no network), see its sibling
20
+ // scripts/gds/credential-presence.js and the inventory in docs/credentials.md (task 1002880).
21
+ //
19
22
  // Exit codes: 0 = credential can read Actions (gate is real, or the workflow is
20
23
  // legitimately absent), 1 = BLIND (remediation printed), 2 = nothing to check.
21
24
 
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // This is the deploy step of the GRADED GDS pipeline: ship.js's auto-merge and
5
5
  // /merge-mode call it after a task has passed the grader + the Archon-only
6
- // confirm gate. It carries the OTB_ALLOW_MAIN_PUSH override so the SEC #863
6
+ // confirm gate. It carries the <PREFIX>_ALLOW_MAIN_PUSH override so the SEC #863
7
7
  // pre-push hook lets the legitimate, already-gated pipeline land main while
8
8
  // still blocking an ad-hoc `git push origin main` (the Builder 25 move).
9
9
  //
@@ -22,11 +22,22 @@
22
22
  'use strict';
23
23
 
24
24
  const { execFileSync } = require('node:child_process');
25
+ const { envPrefix } = require('../../src/instance-config');
26
+
27
+ // The push's environment: the caller's, plus the override under its CANONICAL
28
+ // spelling for this instance — <PREFIX>_ALLOW_MAIN_PUSH, BONGOS_ on a vanilla
29
+ // instance (task 1001441). The hook reads it through resolveEnv, where the
30
+ // canonical name is tried first; writing the legacy OTB_ spelling instead would
31
+ // still work until the legacy prefixes retire, and would earn a deprecation
32
+ // warning on every deploy push in the meantime.
33
+ function pushEnv(env = process.env) {
34
+ return { ...env, [`${envPrefix()}_ALLOW_MAIN_PUSH`]: '1' };
35
+ }
25
36
 
26
37
  function pushMain(extra = []) {
27
38
  execFileSync('git', ['push', 'origin', 'main', ...extra], {
28
39
  stdio: 'inherit',
29
- env: { ...process.env, OTB_ALLOW_MAIN_PUSH: '1' },
40
+ env: pushEnv(),
30
41
  shell: false,
31
42
  });
32
43
  }
@@ -42,4 +53,4 @@ if (require.main === module) {
42
53
  }
43
54
  }
44
55
 
45
- module.exports = { pushMain };
56
+ module.exports = { pushMain, pushEnv };
@@ -30,7 +30,8 @@
30
30
  // node scripts/gds/push-path-brief.js # human-readable: which path + why
31
31
  // node scripts/gds/push-path-brief.js --line # ONE line or nothing; always exit 0
32
32
  //
33
- // Detection override (tests / forcing): OTB_PUSH_VIA_SERVER=1 (server) / =0 (local).
33
+ // Detection override (tests / forcing): <PREFIX>_PUSH_VIA_SERVER=1 (server) / =0 (local)
34
+ // — BONGOS_PUSH_VIA_SERVER on a vanilla instance; the legacy OTB_ spelling is still read.
34
35
 
35
36
  const { hasFlag } = require('./cli-lib');
36
37
  const { plainCardLines } = require('./plain-cards');
@@ -56,7 +57,7 @@ function briefLine(via) {
56
57
  // ---- detection ---------------------------------------------------------------
57
58
 
58
59
  // Lazy require so a broken or partial checkout degrades to silence, not a stack
59
- // trace injected into the session. pushVia() honors OTB_PUSH_VIA_SERVER, else
60
+ // trace injected into the session. pushVia() honors <PREFIX>_PUSH_VIA_SERVER, else
60
61
  // probes for an authenticated gh (absent on every box → 'server' instantly).
61
62
  function detectVia() {
62
63
  return require('./ship-deploy-target.js').pushVia();
@@ -15,7 +15,7 @@
15
15
  //
16
16
  // Each step routes its stdout by `sink: discord|gds|none`. A failed step exits
17
17
  // non-zero after a best-effort #ops alert. Runs the steps with
18
- // OTB_COST_CRON_ROUTINE set so System 1 tags any (hybrid) model spend as
18
+ // <PREFIX>_COST_CRON_ROUTINE set so System 1 tags any (hybrid) model spend as
19
19
  // cron:<routine>. Usage: node scripts/gds/run-routine.js <routine-name> [--dry-run]
20
20
 
21
21
  'use strict';
@@ -23,7 +23,7 @@
23
23
  const fs = require('fs');
24
24
  const path = require('path');
25
25
  const { execFileSync } = require('child_process');
26
- const { resolveCoreRoot, resolveInstanceRoot } = require('../../src/instance-config');
26
+ const { resolveCoreRoot, resolveInstanceRoot, resolveEnv, envPrefix } = require('../../src/instance-config');
27
27
 
28
28
  // TWO roots (ADR 0108 configurable-root; task 2138). run-routine.js is a CORE script, so it
29
29
  // ships alongside src/, scripts/gds/*, scripts/discord/*, and modules/ — all under
@@ -39,7 +39,20 @@ const TASKS_DIR = path.join(INSTANCE_ROOT, '.claude', 'scheduled-tasks');
39
39
  const DISCORD_POST = path.join(CORE_ROOT, 'scripts', 'discord', 'post.js');
40
40
  const VALID_MODES = new Set(['deterministic', 'hybrid', 'judgment']);
41
41
  const VALID_SINKS = new Set(['discord', 'gds', 'none']);
42
- const ALERT_CHANNEL = process.env.OTB_ROUTINE_ALERT_CHANNEL || 'ship_feed';
42
+ // The #ops channel a failed step alerts: <PREFIX>_ROUTINE_ALERT_CHANNEL (BONGOS_ on
43
+ // a vanilla instance; the legacy OTB_ spelling is still read), else ship_feed.
44
+ function alertChannel(env = process.env) {
45
+ return resolveEnv('ROUTINE_ALERT_CHANNEL', { env }) || 'ship_feed';
46
+ }
47
+ const ALERT_CHANNEL = alertChannel();
48
+
49
+ // The environment a routine step runs with: the caller's, plus the routine's name
50
+ // under the canonical <PREFIX>_COST_CRON_ROUTINE so System 1 tags any model spend
51
+ // as cron:<routine>. session-cost.js reads it back through resolveEnv, which tries
52
+ // the canonical spelling first (task 1001441).
53
+ function stepEnv(routineName, env = process.env) {
54
+ return { ...env, [`${envPrefix()}_COST_CRON_ROUTINE`]: routineName };
55
+ }
43
56
 
44
57
  function unquote(s) {
45
58
  const t = String(s == null ? '' : s).trim();
@@ -177,7 +190,7 @@ function runStep(step, routineName, { dryRun }) {
177
190
  const out = execFileSync(argv[0], argv.slice(1), {
178
191
  cwd: INSTANCE_ROOT, // routines run in the instance repo (companions + git-touching steps)
179
192
  encoding: 'utf8',
180
- env: { ...process.env, OTB_COST_CRON_ROUTINE: routineName },
193
+ env: stepEnv(routineName),
181
194
  });
182
195
  routeOutput(step.sink, out, routineName);
183
196
  return out;
@@ -241,4 +254,4 @@ if (require.main === module) {
241
254
  main();
242
255
  }
243
256
 
244
- module.exports = { parseRoutineFrontmatter, tokenize, resolveStepArgv, isNodeExec, VALID_MODES, VALID_SINKS };
257
+ module.exports = { parseRoutineFrontmatter, tokenize, resolveStepArgv, isNodeExec, VALID_MODES, VALID_SINKS, alertChannel, stepEnv };
@@ -95,8 +95,11 @@ const INTEGRATION = new Set([
95
95
  'asset_version', 'auth_flow_slice1', 'backfill_sessions', 'bfg', 'bfg_write',
96
96
  'box_connect_e2e', 'box_onboard', 'boxes', 'cf_tunnel', 'cli_token_reissue',
97
97
  // consumer_layout_boot builds the core .tgz, `npm install`s it, and boots a live
98
- // platform-server — network + package build + a spawned server, run by its own
99
- // .github/workflows/consumer-layout-boot.yml, not this DB-free gate (task 2142).
98
+ // platform-server (task 2142), then installs the package from its own shipped lockfile,
99
+ // migrates brand-new EMPTY databases and serves a database-backed route from one (task
100
+ // 1002863) — network + package build + spawned servers + Postgres. It runs in the
101
+ // integration job of .github/workflows/integration.yml like the rest of this set; there
102
+ // never was a dedicated consumer-layout-boot workflow, whatever older notes say.
100
103
  'consumer_layout_boot',
101
104
  'claim_gate_open_blocker', 'claim_gate_rebase',
102
105
  // task 1002709: core_221's re-keyed backstop index against a real Postgres.
@@ -408,11 +411,19 @@ function discoverModuleTests({ modulesDir = MODULES_DIR, isEnabled = isModuleEna
408
411
  // scripts/gds/smoke-box.sh still invokes all three by path — deleting the files
409
412
  // turned that script into an immediate MODULE_NOT_FOUND. They come out when the
410
413
  // module does, in the change that removes it.
414
+ //
415
+ // consumer_layout_boot LEFT THIS LIST under task 1002863. Its "missing core tarball" was
416
+ // package-core's fail-closed leak gate reading the lane's throwaway database password (the
417
+ // word postgres, present in hundreds of core files) as a leaked credential and refusing to
418
+ // build. The harness now withholds database settings from the packager, and it is the only
419
+ // test that proves the PUBLISHED package installs, migrates an empty database and serves
420
+ // from it — parking it again re-opens that gap, which tests/integration_parked_ratchet.mjs
421
+ // now says out loud.
411
422
  const INTEGRATION_PARKED = new Set([
412
423
  'acclaim_feed_evidence_db',
413
424
  'bfg', 'bfg_write', 'box_connect_e2e', 'box_onboard', 'boxes',
414
425
  'claim_gate_open_blocker', 'claim_gate_rebase',
415
- 'clawback_confirmed_backlog', 'consumer_layout_boot', 'copy_desk_flags_db',
426
+ 'clawback_confirmed_backlog', 'copy_desk_flags_db',
416
427
  'government_board_membership_db', 'handle_machinery', 'hub_profile_leak',
417
428
  'idea_credit_streams_db', 'memory_isolation', 'memory_pull', 'needs_rebase',
418
429
  'profile_fields', 'project_visibility_db', 'reward_mode_cost_plus_only',
@@ -27,6 +27,7 @@ const os = require('os');
27
27
  const path = require('path');
28
28
  const { cliClient, loadSession, arg } = require('./cli-lib');
29
29
  const { priceUsage } = require('../../src/bongos/llm-pricing');
30
+ const { resolveEnv } = require('../../src/instance-config');
30
31
 
31
32
 
32
33
  // Claude Code encodes a project transcript dir by replacing every non-alnum
@@ -256,9 +257,16 @@ function priceSubagents(sessionDir) {
256
257
  // into TWO call-site-tagged rows — source='<base>:interactive' (source_ref =
257
258
  // sessionId) and '<base>:subagent' (source_ref = sessionId+':sub'), distinct on
258
259
  // the (source,source_ref) idempotency index — each carrying its tier split. A
259
- // cron-launched session sets OTB_COST_CRON_ROUTINE=<name> so base='cron:<name>'
260
+ // cron-launched session sets <PREFIX>_COST_CRON_ROUTINE=<name> so base='cron:<name>'
260
261
  // instead of 'session'. The rows' amounts reconcile to the prior single-row
261
262
  // figure (priorSingle vs rowsTotal, equal within independent-rounding epsilon).
263
+ // The routine a cron-launched session belongs to, or ''. run-routine.js writes
264
+ // the canonical <PREFIX>_COST_CRON_ROUTINE (stepEnv); a legacy OTB_ spelling set
265
+ // by an older runner is still read, with a deprecation warning (task 1001441).
266
+ function cronRoutineFromEnv(env = process.env) {
267
+ return (resolveEnv('COST_CRON_ROUTINE', { env }) || '').trim();
268
+ }
269
+
262
270
  function buildCostRows({ sessionId, inter, sub, cronRoutine }) {
263
271
  const base = cronRoutine ? `cron:${cronRoutine}` : 'session';
264
272
  const id8 = String(sessionId).slice(0, 8);
@@ -350,8 +358,8 @@ async function main() {
350
358
  if (!(total > 0)) return;
351
359
 
352
360
  // task 1344: split into call-site-tagged, tier-carrying rows; cron sessions
353
- // are tagged via OTB_COST_CRON_ROUTINE.
354
- const cronRoutine = (process.env.OTB_COST_CRON_ROUTINE || '').trim();
361
+ // are tagged via <PREFIX>_COST_CRON_ROUTINE (see cronRoutineFromEnv).
362
+ const cronRoutine = cronRoutineFromEnv();
355
363
  const built = buildCostRows({ sessionId, inter, sub, cronRoutine });
356
364
  const id8 = sessionId.slice(0, 8);
357
365
 
@@ -414,6 +422,7 @@ module.exports = {
414
422
  newestTranscriptFor,
415
423
  // task 1344 — pure tier-split + row-building helpers (unit-tested)
416
424
  buildCostRows,
425
+ cronRoutineFromEnv,
417
426
  emptyTiers,
418
427
  addComponents,
419
428
  addTiers,
@@ -12,13 +12,17 @@ const { spawnSync } = require('node:child_process');
12
12
  const fs = require('node:fs');
13
13
  const path = require('node:path');
14
14
  const { loadSessionSync } = require('./cli-lib');
15
+ // The two env overrides below resolve through the branded prefix — <PREFIX>_DEPLOY_MODE /
16
+ // <PREFIX>_PUSH_VIA_SERVER (BONGOS_ on a vanilla instance) — with the legacy OTB_ spelling
17
+ // still honoured and warned about (task 1001441, R61 follow-up).
18
+ const { resolveEnv } = require('../../src/instance-config');
15
19
  const { argvOk, argvOut } = require('./ship-git.js');
16
20
  const { REPO_ROOT } = require('./ship-state.js');
17
21
 
18
22
  // ---------- CI deploy mode (#679, ADR 0042) ----------
19
23
  //
20
24
  // Two ways the confirmed→shipped land can run, chosen by a SINGLE committed
21
- // switch (config/deploy.json `mode`, env OTB_DEPLOY_MODE overrides):
25
+ // switch (config/deploy.json `mode`, env <PREFIX>_DEPLOY_MODE overrides):
22
26
  //
23
27
  // 'laptop' (default) — the operator's machine merges to main locally and, IFF a
24
28
  // deploy target is configured (config/deploy.json "sshTarget" or
@@ -63,7 +67,7 @@ function parseDeployMode(envVal, configRaw) {
63
67
  // it must ship via the 'ci' server-mediated publish path instead
64
68
  // (pushVia()==='server', ADR 0055), which lets the GDS server push with its own
65
69
  // credential. So when the parsed mode is 'laptop' but the machine can't push
66
- // locally AND the mode was NOT pinned by an explicit OTB_DEPLOY_MODE, fall back to
70
+ // locally AND the mode was NOT pinned by an explicit <PREFIX>_DEPLOY_MODE, fall back to
67
71
  // 'ci' rather than strand every ship at merge. This is the scootermack 2026-06-22
68
72
  // case: a checkout left at the pre-cutover 'laptop' default that can't pull
69
73
  // forward to 'ci', so it kept attempting a push it had no credential for. An
@@ -109,9 +113,10 @@ function mainIsProtected() {
109
113
 
110
114
  function resolveDeployMode() {
111
115
  const configRaw = readDeployConfigRaw();
112
- const mode = parseDeployMode(process.env.OTB_DEPLOY_MODE, configRaw);
116
+ const envMode = resolveEnv('DEPLOY_MODE');
117
+ const mode = parseDeployMode(envMode, configRaw);
113
118
  const envForced = ['ci', 'laptop'].includes(
114
- String(process.env.OTB_DEPLOY_MODE || '').trim().toLowerCase()
119
+ String(envMode || '').trim().toLowerCase()
115
120
  );
116
121
  // Probe `gh` ONLY when it can change the answer (resolved 'laptop', no env
117
122
  // override) — the happy 'ci' path and an env-pinned mode spawn no extra gh,
@@ -313,7 +318,7 @@ function fetchDeployedVersion(url = deployedVersionUrl()) {
313
318
  // credential-less origin). ship uploads a thin branch bundle to the
314
319
  // GDS, which pushes + opens the PR + auto-merges with the SERVER's
315
320
  // credential. The box holds nothing (#919 model preserved).
316
- // Override with OTB_PUSH_VIA_SERVER=1 (force server) / =0 (force local) for tests.
321
+ // Override with <PREFIX>_PUSH_VIA_SERVER=1 (force server) / =0 (force local) for tests.
317
322
  function ghAvailableAndAuthed() {
318
323
  try {
319
324
  const a = spawnSync('gh', ['auth', 'status'], {
@@ -329,7 +334,7 @@ function ghAvailableAndAuthed() {
329
334
  }
330
335
 
331
336
  function pushVia() {
332
- const o = String(process.env.OTB_PUSH_VIA_SERVER || '').trim();
337
+ const o = String(resolveEnv('PUSH_VIA_SERVER') || '').trim();
333
338
  if (o === '1') return 'server';
334
339
  if (o === '0') return 'local';
335
340
  // No explicit override: a machine that can run an authenticated gh (laptop)
@@ -20,6 +20,8 @@ const { plainCardLines } = require('./plain-cards.js');
20
20
  // gives, and the two can never drift.
21
21
  const { preflightChecks } = require('./preflight.js');
22
22
  const { computeRawDiffHash, reconstructPanelIssues, runNoArtifactGrader, runSubagentGrader, shouldReuseGrade } = require('./ship-grade.js');
23
+ // task 1002903: the severity gate's own reading of which low scores had no finding.
24
+ const { unbackedLowScores } = require('../../modules/grading/grader-workers/worker-verdict.js');
23
25
 
24
26
  // ---------- Shared grade-step helpers (used by shipMain AND regradeMain) ----------
25
27
  //
@@ -396,6 +398,37 @@ function formatGradeOutcome(g, taskId, cliFinding = null) {
396
398
  return out.join('\n');
397
399
  }
398
400
 
401
+ // task 1002903: a reviewer scored below threshold and named NO major/blocker
402
+ // finding, so the severity gate (task 1002663) downgraded it — the ship parks for
403
+ // a human rather than hard-failing. The ordinary block below would print "FAIL"
404
+ // and "Fix the issues above" over an empty list, which is how task 1002721's
405
+ // builder read a real grade as a broken one. Sits below the outage and
406
+ // authorization shapes, and steps aside when any finding IS substantive or a
407
+ // gating worker hard-failed (then the ordinary block and its findings apply).
408
+ // The record's own list wins; the workers are the fallback for a grade written
409
+ // before signals.unbacked_scores existed.
410
+ const gradeThreshold = Number(g.threshold);
411
+ const unbacked = Array.isArray(g.signals?.unbacked_scores)
412
+ ? g.signals.unbacked_scores
413
+ : Number.isFinite(gradeThreshold) ? unbackedLowScores(g.signals?.workers, { threshold: gradeThreshold }) : [];
414
+ const hasSubstantive = (g.issues || []).some((i) => i && (i.severity === 'blocker' || i.severity === 'major'));
415
+ if (!g.passed && unbacked.length > 0 && !hasSubstantive && g.signals?.panel_outcome !== 'fail') {
416
+ out.push('');
417
+ out.push(`Subagent grade: NOT PASSED — UNBACKED LOW SCORE · a reviewer scored below ${g.threshold ?? '?'} without naming a finding — NOT a findings-backed rejection`);
418
+ for (const u of unbacked) {
419
+ out.push(` ${u.kind}: ${u.axis} ${u.score}/10 — no major or blocker finding stands behind it`);
420
+ }
421
+ if (g.notes_md) out.push(g.notes_md.split('\n').map((l) => ' ' + l).join('\n'));
422
+ out.push('');
423
+ out.push('There is nothing listed to fix. The severity gate does not let an unbacked score hard-fail a ship, so the task stays at completed for a human instead.');
424
+ out.push('Re-grade once — an unbacked score often does not repeat:');
425
+ out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
426
+ out.push('If it repeats on work you can defend, file an override-request with your evidence (recorded; an Archon decides):');
427
+ out.push(` node scripts/gds/api.js POST /api/bongos/tasks/${taskId}/override-request --body-file rationale.json # {"rationale":"<why the grade is wrong, specifically>"}`);
428
+ out.push(...plainCardLines('grade_unbacked_score'));
429
+ return out.join('\n');
430
+ }
431
+
399
432
  const verdict = g.passed ? 'PASS' : 'FAIL';
400
433
  out.push('');
401
434
  out.push(`Subagent grade: ${verdict} · blended ${g.score}/10 · threshold ${g.threshold} (both axes)`);
@@ -16,6 +16,9 @@ const { gitArgvOk, gitOk, sanitizeGitHubRefs, shellOk } = require('./ship-git.js
16
16
  const { buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded, deployedCommitStatus, fetchDeployedVersion, ghArgvOk, ghJson, pushVia, resolveInstanceBase } = require('./ship-deploy-target.js');
17
17
  const { normalizeApiError } = require('./ship-io.js');
18
18
  const { plainCardLines } = require('./plain-cards.js');
19
+ // The server's own reader of a PR's check rollup (task 1003988), reused so the laptop
20
+ // poll names a red PR exactly the way publish-status does (task 1003764). Dependency-free.
21
+ const { readCheckRollup, UNIT_REPORT_CONTEXT } = require('../../modules/lifecycle/check-rollup.js');
19
22
 
20
23
  // ---------- the land outcome (task 1002541, part 1) ----------
21
24
  //
@@ -284,7 +287,19 @@ function strandNextStep(taskId, via) {
284
287
  // too: the server flags a strand needs_rebase (definitive shapes within a sweep,
285
288
  // ambiguous ones at ~30 min), /builder-start warns, and new claims gate on it.
286
289
  // Pure + exported so the wording is pinned by tests/ship_ci_deploy.mjs.
287
- function landPendingLines(taskId, { merged } = {}) {
290
+ //
291
+ // task 1003764: `red` — the PR was RED on the poll's last read. "NOT a failure" is then
292
+ // false: a required check that failed does not pass by waiting, so the banner says the
293
+ // land is stuck on it and defers to the red lines above for the remedy.
294
+ function landPendingLines(taskId, { merged, red } = {}) {
295
+ if (red) {
296
+ return [
297
+ ' ci-land: Published, but the PR is RED (the check named above), so it has NOT merged and will not',
298
+ ' until that check passes. The task stays at \'confirmed\'. Do what the lines above say, then re-run',
299
+ ` \`node scripts/gds/ship.js ${taskId}\` — it resumes from here (task 941). If it stays red, the server flags`,
300
+ ' the task needs_rebase — /builder-start will warn you and new claims are gated until it lands or is released.',
301
+ ];
302
+ }
288
303
  return [
289
304
  ` ci-land: ✓ Published — auto-merge is armed${merged ? ' and the PR has merged' : ''}. This is NOT a failure.`,
290
305
  ` CI is still finishing; GitHub will ${merged ? 'deploy' : 'merge + deploy'} automatically once the required checks pass,`,
@@ -298,7 +313,9 @@ function landPendingLines(taskId, { merged } = {}) {
298
313
  function printLandPending(taskId, opts = {}) {
299
314
  console.log('');
300
315
  for (const line of landPendingLines(taskId, opts)) console.log(line);
301
- for (const line of plainCardLines('land_pending')) console.log(line);
316
+ // The land_pending card says there is nothing to do, which a red PR makes untrue; the
317
+ // red line already printed the failing_check card (task 1003764).
318
+ if (!opts.red) for (const line of plainCardLines('land_pending')) console.log(line);
302
319
  }
303
320
 
304
321
  // task 1002645 — the honest close-out when the PR MERGED but the server refused the
@@ -349,9 +366,97 @@ function redPrLine(md) {
349
366
  // redPrLines — the red line followed by the same reason in plain words (task 1003989).
350
367
  // The card is keyed on the merge reason; when the failing tests were readable that
351
368
  // reason is `failing_check:<name>`, which resolves to the failing_check card.
352
- function redPrLines(md) {
369
+ // task 1003764: the merge trap sits between them. `opts` = { taskId, via } for its wording.
370
+ function redPrLines(md, opts = {}) {
353
371
  const red = redPrLine(md);
354
- return red ? [red, ...plainCardLines((md && md.reason) || 'failing_check')] : [];
372
+ return red ? [red, ...mergeTrapLines(md, opts), ...plainCardLines((md && md.reason) || 'failing_check')] : [];
373
+ }
374
+
375
+ // The unit tests that gate GENERATED files — each compares a committed artifact with a
376
+ // fresh build of it. `fitness` holds the combined gate (fitness.js "generated artifacts
377
+ // fresh": copy registry, API docs, file map, nested CLAUDE.md blocks, the §13 snippet).
378
+ // tests/ship_land_red_pr.mjs fails if one of these names stops being a real test file.
379
+ const FRESHNESS_TESTS = new Set(['fitness', 'api_docs', 'api_client', 'file_map', 'repo_map', 'session_index', 'interaction_posture']);
380
+
381
+ // failingTestNames — "2 failed: a, b" (the unit-report line, run-unit-tests.js
382
+ // describeFailures) → ['a', 'b']. A line cut at the status limit ends in '…'; the cut
383
+ // name is dropped rather than guessed at. Pure.
384
+ function failingTestNames(text) {
385
+ const m = /^\s*\d+\s+failed:\s*(.*)$/.exec(String(text || ''));
386
+ if (!m) return [];
387
+ const names = m[1].split(',').map((s) => s.trim()).filter(Boolean);
388
+ if (names.length && names[names.length - 1].endsWith('…')) names.pop();
389
+ return names;
390
+ }
391
+
392
+ // mergeTrapLines — task 1003764. CI tests the MERGE, not the branch: a pull_request run
393
+ // checks out the PR merged into the CURRENT main, so a branch that was green when pushed
394
+ // goes red when main moves under it, and re-running the test on the branch shows nothing
395
+ // wrong. Task 1003747's land sat "waiting" for ~20 minutes of git archaeology on exactly
396
+ // that — main moved six commits, the copy registry went stale in the merged tree only.
397
+ // Every red PR gets the trap named; a failing freshness gate also gets its healing command,
398
+ // which differs by push path (a credential-less machine cannot merge main locally, and the
399
+ // server heals it for them — publish-reconciler refreshStaleGenerated). Pure + exported.
400
+ function mergeTrapLines(md, { taskId = '<id>', via = 'local' } = {}) {
401
+ const lines = [
402
+ ' ci-land: CI tests your branch MERGED with the current main, not the branch alone — if the failure',
403
+ via === 'server'
404
+ ? ' does not reproduce on your branch, main moved under the PR after it was pushed.'
405
+ : ' does not reproduce on your branch, main moved under it: `git fetch origin && git merge origin/main`, then re-run it.',
406
+ ];
407
+ const gates = failingTestNames(md && md.failing_tests).filter((n) => FRESHNESS_TESTS.has(n));
408
+ if (!gates.length) return lines;
409
+ const named = gates.map((g) => `\`${g}\``).join(', ');
410
+ lines.push(` ci-land: ${named} ${gates.length > 1 ? 'are freshness gates' : 'is a freshness gate'}, so "fix it" here means regenerate: a generated file (copy`);
411
+ lines.push(' registry, API docs, repo map…) is stale in the MERGED tree even though your branch was fresh.');
412
+ if (via === 'server') {
413
+ lines.push(' The server regenerates stale generated files on the merged tree itself (every ~5 min, up to 3 tries),');
414
+ lines.push(' so this usually clears without you; if it stays red the server flags the task needs_rebase.');
415
+ } else {
416
+ lines.push(` Heal it: \`git fetch origin && git merge origin/main\`, then re-run \`node scripts/gds/ship.js ${taskId}\` — the ship`);
417
+ lines.push(' regenerates every generated file on the merged tree before it pushes (`node scripts/gds/ship-check.js` shows which).');
418
+ }
419
+ lines.push(' A busy main can re-stale it while CI re-runs; a repeat is that race, not a new fault.');
420
+ return lines;
421
+ }
422
+
423
+ // redBlocksMerge — is this red the kind that stops the merge? A failing REQUIRED check
424
+ // (`failing_check…`) does; a failing optional one does not (GitHub still merges an UNSTABLE
425
+ // PR), so only the first may turn the closing banner red (task 1003764). Pure + exported.
426
+ function redBlocksMerge(md) {
427
+ return /^failing_check/.test(String((md && md.reason) || ''));
428
+ }
429
+
430
+ // A check conclusion (CheckRun) or state (status context) that means FAILED. Pending and
431
+ // expected are waiting, not red — reporting them as failures would be the mirror image of
432
+ // the bug this names (a failure dressed as waiting).
433
+ const RED_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);
434
+
435
+ // localMergeDriver — task 1003764. The laptop path watches its PR with the builder's own
436
+ // gh, and read only state + mergeCommit, so a red required check was invisible for the
437
+ // whole wait. This reads `gh pr view --json statusCheckRollup,mergeStateStatus,headRefOid`
438
+ // through the server's parser and answers in publish-status's merge_driver shape, so both
439
+ // poll paths hand redPrLines the same thing. `failingTestsFor(sha)` reads the unit-report
440
+ // status (gh pr view omits a status's description) and is asked only once that status is
441
+ // itself red, i.e. posted. Null when nothing is red. Pure + exported.
442
+ function localMergeDriver(pr, failingTestsFor = () => null) {
443
+ if (!pr || pr.state !== 'OPEN' || !Array.isArray(pr.statusCheckRollup)) return null;
444
+ const { checks } = readCheckRollup({ contexts: { nodes: pr.statusCheckRollup } });
445
+ const red = checks.filter((c) => RED_CONCLUSIONS.has(String(c.conclusion || '').toUpperCase()));
446
+ if (!red.length) return null;
447
+ const kind = pr.mergeStateStatus === 'UNSTABLE' ? 'non_required_check_failing' : 'failing_check';
448
+ const reported = red.some((c) => c.name === UNIT_REPORT_CONTEXT);
449
+ return { reason: `${kind}:${red[0].name}`, failing_tests: (reported && failingTestsFor(pr.headRefOid)) || null };
450
+ }
451
+
452
+ // unitReportFor — the failing-test line the `unit` workflow posted on `sha`, read with this
453
+ // machine's gh through the same parser the server uses. Null when unreadable; the red line
454
+ // then names the check instead, as it did before the report existed. `gh` is injectable.
455
+ function unitReportFor(sha, gh = ghJson) {
456
+ if (!/^[0-9a-f]{7,64}$/i.test(String(sha || ''))) return null;
457
+ const combined = gh(['api', `repos/{owner}/{repo}/commits/${sha}/status`]);
458
+ const nodes = ((combined && combined.statuses) || []).map((s) => ({ ...s, __typename: 'StatusContext' }));
459
+ return readCheckRollup({ contexts: { nodes } }).failing_tests;
355
460
  }
356
461
 
357
462
  // task 1003499 — a CONFLICTING PR is a land that cannot happen, however green it is.
@@ -386,7 +491,7 @@ function conflictPrLines(taskId) {
386
491
 
387
492
  // noteConflict — one poll's worth of conflict tracking, shared by both loops. Prints
388
493
  // the lines above when the PR BECOMES conflicting (not on every 10s poll, and once
389
- // more if main moves on and undoes a heal), and returns what the PR looks like now.
494
+ // more if main moves on and undoes a heal), and returns whether it conflicts now.
390
495
  // A failed read keeps the last answer rather than inventing a new one.
391
496
  function noteConflict(taskId, pr, wasConflicting) {
392
497
  if (!pr) return wasConflicting;
@@ -434,14 +539,27 @@ async function pollServerPublish(taskId, branch, deps = {}) {
434
539
  let blockedReason = null;
435
540
  let redLine = null;
436
541
  let conflicting = false;
542
+ let stuckRed = false; // task 1003764: a failing required check on the LAST read turns the closing banner red
543
+ let readFailure = null;
437
544
  for (let i = 0; i < TRIES; i++) {
438
545
  const r = await readStatus({ id: taskId, query: { branch } });
546
+ if (!r.ok) {
547
+ // task 1003764: a failed status read used to be skipped in silence, so a broken
548
+ // diagnostic looked exactly like slow CI. Say so once per distinct failure.
549
+ const { code } = normalizeApiError(r.data);
550
+ const why = `HTTP ${r.status}${code ? ` ${code}` : ''}`;
551
+ if (why !== readFailure) {
552
+ readFailure = why;
553
+ console.log(` ci-land: could not read the land status from the server (${why}) — still polling.`);
554
+ }
555
+ }
439
556
  if (r.ok && r.data) {
440
557
  const red = redPrLine(r.data.merge_driver);
441
558
  if (red && red !== redLine) {
442
559
  redLine = red;
443
- for (const line of redPrLines(r.data.merge_driver)) console.log(line);
560
+ for (const line of redPrLines(r.data.merge_driver, { taskId, via: 'server' })) console.log(line);
444
561
  }
562
+ stuckRed = !!red && redBlocksMerge(r.data.merge_driver);
445
563
  conflicting = noteConflict(taskId, r.data.merge_driver, conflicting);
446
564
  const outcome = classifyLandOutcome(r.data);
447
565
  if (outcome === 'shipped') {
@@ -469,7 +587,7 @@ async function pollServerPublish(taskId, branch, deps = {}) {
469
587
  return landBailed(`the PR merged but the server could not prove the land is this task's: ${blockedReason}`, `node scripts/gds/ship.js ${taskId}`);
470
588
  }
471
589
  if (conflicting) return landConflicted(taskId);
472
- printLandPending(taskId);
590
+ printLandPending(taskId, { red: stuckRed });
473
591
  return landPending();
474
592
  }
475
593
 
@@ -793,11 +911,22 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
793
911
  console.log(' ci-land: waiting for auto-merge (checks + branch protection)...');
794
912
  let mergeSha = null;
795
913
  let conflicting = false;
914
+ // task 1003764: say WHY it has not merged. This loop read only state + mergeCommit,
915
+ // so a red required check — the likeliest reason — went unnamed for the whole wait.
916
+ // The unit-report line (which failing tests) is fetched once per head sha.
917
+ let redLine = null;
918
+ let stuckRed = false;
919
+ let report = { sha: undefined, text: null };
920
+ const failingTestsFor = (sha) => {
921
+ if (sha !== report.sha) report = { sha, text: unitReportFor(sha, readJson) };
922
+ return report.text;
923
+ };
796
924
  for (let i = 0; i < MERGE_TRIES; i++) {
797
925
  // task 1003499: mergeable + mergeStateStatus ride the same read, so a conflict is
798
926
  // named on the poll it appears; the two-field read is the fallback for a gh that
799
927
  // predates those fields, which keeps this no worse than before on any machine.
800
- const pr = readJson(['pr', 'view', branch, '--json', 'state,mergeCommit,mergeable,mergeStateStatus'])
928
+ // task 1003764: so do headRefOid + statusCheckRollup, so a red check is named too.
929
+ const pr = readJson(['pr', 'view', branch, '--json', 'state,mergeCommit,mergeable,mergeStateStatus,headRefOid,statusCheckRollup'])
801
930
  || readJson(['pr', 'view', branch, '--json', 'state,mergeCommit']);
802
931
  if (pr && pr.state === 'MERGED') {
803
932
  mergeSha = pr.mergeCommit && pr.mergeCommit.oid;
@@ -807,6 +936,15 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
807
936
  console.log(` ci-land: PR was CLOSED without merging (a required check failed, or it was closed manually). Fix it and re-run \`node scripts/gds/ship.js ${taskId}\` to resume. Task stays at 'confirmed'.`);
808
937
  return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
809
938
  }
939
+ if (pr) {
940
+ const md = localMergeDriver(pr, failingTestsFor);
941
+ const red = redPrLine(md);
942
+ if (red && red !== redLine) {
943
+ redLine = red;
944
+ for (const line of redPrLines(md, { taskId, via: 'local' })) console.log(line);
945
+ }
946
+ stuckRed = !!red && redBlocksMerge(md);
947
+ }
810
948
  conflicting = noteConflict(taskId, pr, conflicting);
811
949
  pause();
812
950
  }
@@ -814,14 +952,15 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
814
952
  // task 1049: PR open + auto-merge armed but not merged in-window = the publish
815
953
  // SUCCEEDED and is still in CI — success-pending, not a failure. Unless it is
816
954
  // CONFLICTING (task 1003499): then nothing is still in CI, and it never merges.
955
+ // Or RED (task 1003764): then the banner says the land is stuck on that check.
817
956
  if (conflicting) return landConflicted(taskId);
818
- printLandPending(taskId);
957
+ printLandPending(taskId, { red: stuckRed });
819
958
  return landPending();
820
959
  }
821
960
  console.log(` ci-land: merged to main (${mergeSha.slice(0, 8)}). Waiting for the prod deploy...`);
822
961
 
823
962
  for (let i = 0; i < DEPLOY_TRIES; i++) {
824
- const runs = ghJson([
963
+ const runs = readJson([
825
964
  'run', 'list', '--workflow', 'deploy-prod.yml', '--branch', 'main',
826
965
  '--json', 'headSha,status,conclusion', '--limit', '15',
827
966
  ]);
@@ -846,7 +985,7 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
846
985
  }
847
986
  return landed();
848
987
  }
849
- shellOk(POLL_SLEEP_CMD);
988
+ pause();
850
989
  }
851
990
  // task 1049: merged but the deploy run hasn't confirmed in-window — still
852
991
  // success-pending (it deploys on its own), not a failure.
@@ -890,6 +1029,13 @@ module.exports = {
890
1029
  landConflicted,
891
1030
  pollCiDeploy,
892
1031
  pollServerPublish,
1032
+ // task 1003764: name WHY a published PR has not merged when a check is red.
1033
+ FRESHNESS_TESTS,
1034
+ failingTestNames,
1035
+ mergeTrapLines,
1036
+ redBlocksMerge,
1037
+ localMergeDriver,
1038
+ unitReportFor,
893
1039
  reconciledCardStatus,
894
1040
  strandCommand,
895
1041
  strandNextStep,
@@ -32,6 +32,7 @@ const { gateMergeResult } = require('./ship-merge-gate.js');
32
32
  const { dbOnlyIgnoredNotice } = require('./ship-honesty.js');
33
33
  const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateCopyInventory, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
34
34
  const { REPO_ROOT } = require('./ship-state.js');
35
+ const { envPrefix } = require('../../src/instance-config');
35
36
 
36
37
  // zeroCommitVerdict — dbOnly is deliberately consulted ONLY at zero commits
37
38
  // ahead: a branch carrying real commits must be merged, whatever flag was typed.
@@ -287,7 +288,7 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
287
288
 
288
289
  console.log(' pushing merged main to origin...');
289
290
  // SEC #863: route through push-main.js, which carries the
290
- // OTB_ALLOW_MAIN_PUSH override so the pre-push permission gate lets THIS
291
+ // <PREFIX>_ALLOW_MAIN_PUSH override so the pre-push permission gate lets THIS
291
292
  // graded+confirmed pipeline push through while still blocking ad-hoc
292
293
  // `git push origin main`. process.execPath (quoted) for the same
293
294
  // cross-platform reason as the other node spawns here (#660).
@@ -298,7 +299,7 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
298
299
  // the ci PR path before this point; reaching this with protection on
299
300
  // means an env-forced 'laptop' or a failed protection probe.
300
301
  if (mainIsProtected() === true) {
301
- return landBailed('main is branch-protected — the direct push can never land; unset OTB_DEPLOY_MODE (the ci PR path lands it)', `node scripts/gds/ship.js ${taskId}`);
302
+ return landBailed(`main is branch-protected — the direct push can never land; unset the DEPLOY_MODE env override (${envPrefix()}_DEPLOY_MODE, or the legacy OTB_DEPLOY_MODE) — the ci PR path lands it`, `node scripts/gds/ship.js ${taskId}`);
302
303
  }
303
304
  return landBailed('could not push the merged main to origin', '/merge-mode');
304
305
  }