@bongos/core 1.19.1032 → 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 (151) hide show
  1. package/.bongos-core.json +322 -117
  2. package/.claude/skills/builder-start/SKILL.md +1 -1
  3. package/.claude/skills/grade-audit/SKILL.md +1 -1
  4. package/.claude/skills/grade-recover/SKILL.md +2 -1
  5. package/.claude/skills/grader-health/SKILL.md +5 -1
  6. package/.claude/skills/merge-mode/SKILL.md +1 -1
  7. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
  8. package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
  9. package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
  10. package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
  11. package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
  12. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
  13. package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
  14. package/docs/adr/README.md +4 -4
  15. package/docs/api/openapi.json +1 -1
  16. package/docs/architecture.md +4 -3
  17. package/docs/canonical-permissions.md +2 -2
  18. package/docs/copy-inventory.md +34 -30
  19. package/docs/copy-registry.json +69 -33
  20. package/docs/file-map.md +4 -3
  21. package/docs/module-api-changelog.md +4 -0
  22. package/docs/recipes/autobongos-windows-host.md +3 -1
  23. package/docs/recipes/local-dev.md +45 -3
  24. package/docs/recipes/packaging-the-core.md +3 -1
  25. package/docs/recipes/redteam-patrol.md +2 -2
  26. package/docs/recipes/upgrading-the-core.md +1 -1
  27. package/modules/autonomy/cadence.js +52 -0
  28. package/modules/autonomy/gauge.js +57 -9
  29. package/modules/government/protected-surfaces.json +1 -0
  30. package/modules/grading/grade-engine.js +267 -0
  31. package/modules/grading/grader-subagent.js +4 -3
  32. package/modules/grading/grader-workers/index.js +67 -11
  33. package/modules/grading/grader-workers/worker-verdict.js +32 -2
  34. package/modules/grading/grader.js +109 -88
  35. package/modules/hall-ui/public/deploy.js +35 -0
  36. package/modules/hall-ui/public/project-settings.css +7 -0
  37. package/modules/hall-ui/records/builders-roster.md +10 -0
  38. package/modules/hall-ui/records/catalog.md +10 -0
  39. package/modules/hall-ui/records/collaboration.md +14 -0
  40. package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
  41. package/modules/hall-ui/records/oversight-family.md +19 -0
  42. package/modules/hall-ui/records/panel-family.md +19 -0
  43. package/modules/hall-ui/records/reading-rooms.md +12 -0
  44. package/modules/hall-ui/records/record-family.md +13 -0
  45. package/modules/hall-ui/records/reference-room.md +9 -0
  46. package/modules/hall-ui/records/roster-and-ledger.md +9 -0
  47. package/modules/hall-ui/records/sky.md +23 -0
  48. package/modules/lifecycle/dependency-advisory.js +2 -1
  49. package/modules/lifecycle/routes/tasks.js +19 -4
  50. package/modules/lifecycle/ship-preflight.js +91 -0
  51. package/modules/provisioning/control-plane-guard.js +14 -13
  52. package/modules/provisioning/core-upgrade.js +30 -17
  53. package/modules/provisioning/public-refusal.js +44 -0
  54. package/modules/provisioning/routes/disconnect.js +2 -1
  55. package/modules/provisioning/routes/env-manifest.js +5 -4
  56. package/modules/provisioning/routes/provisioning.js +16 -15
  57. package/modules/provisioning/routes/repo-private.js +2 -1
  58. package/package-lock.json +2 -2
  59. package/package.json +1 -1
  60. package/release-notes.json +7481 -0
  61. package/scripts/gds/address-park-sweep.sh +3 -1
  62. package/scripts/gds/autobongos-run.js +91 -3
  63. package/scripts/gds/backfill-cost.js +20 -9
  64. package/scripts/gds/baseline-staleness.js +1 -1
  65. package/scripts/gds/claim.js +2 -2
  66. package/scripts/gds/credential-presence.js +281 -0
  67. package/scripts/gds/db-backup-nightly.sh +9 -4
  68. package/scripts/gds/db-backup-offsite.sh +4 -2
  69. package/scripts/gds/deploy-main-audit-gate.sh +11 -4
  70. package/scripts/gds/dev-lib.js +212 -5
  71. package/scripts/gds/dev.js +102 -50
  72. package/scripts/gds/doctor.js +3 -2
  73. package/scripts/gds/grade-correlation-audit.js +280 -0
  74. package/scripts/gds/grade-replay.js +237 -0
  75. package/scripts/gds/hooks/pre-push.js +7 -3
  76. package/scripts/gds/main-audit.js +19 -10
  77. package/scripts/gds/memory-map.js +3 -1
  78. package/scripts/gds/onboarding-config.js +3 -2
  79. package/scripts/gds/package-core.js +72 -1
  80. package/scripts/gds/plain-cards.js +4 -0
  81. package/scripts/gds/preflight.js +12 -4
  82. package/scripts/gds/provision-core-upgrade.js +282 -80
  83. package/scripts/gds/provision-repo-auth.js +15 -0
  84. package/scripts/gds/provision.js +1 -1
  85. package/scripts/gds/publish-credential-check.js +3 -0
  86. package/scripts/gds/push-main.js +14 -3
  87. package/scripts/gds/push-path-brief.js +3 -2
  88. package/scripts/gds/redteam-patrol.js +4 -4
  89. package/scripts/gds/release-notes.js +271 -0
  90. package/scripts/gds/run-routine.js +18 -5
  91. package/scripts/gds/run-unit-tests.js +14 -3
  92. package/scripts/gds/seed-board-room-tasks.js +1 -1
  93. package/scripts/gds/session-cost.js +12 -3
  94. package/scripts/gds/ship-deploy-target.js +11 -6
  95. package/scripts/gds/ship-flow.js +5 -3
  96. package/scripts/gds/ship-grade-steps.js +33 -0
  97. package/scripts/gds/ship-land.js +241 -16
  98. package/scripts/gds/ship-merge.js +3 -2
  99. package/scripts/gds/ship-preflight-steps.js +86 -11
  100. package/scripts/gds/ship.js +1 -1
  101. package/scripts/gds/stray-unit-sweep.sh +3 -1
  102. package/scripts/gds/upgrade-migrate.js +67 -0
  103. package/scripts/gds/upgrade.js +9 -7
  104. package/scripts/hall-preview/README.md +20 -3
  105. package/scripts/hall-preview/live-identity.js +1 -1
  106. package/scripts/hall-preview/refresh-fixtures.js +71 -11
  107. package/scripts/hall-preview/scrub-identity.js +296 -0
  108. package/src/bongos/routes/security.js +3 -3
  109. package/src/build-info.js +4 -2
  110. package/src/module-api.js +1 -1
  111. package/tests/adr_0089_scope.mjs +70 -0
  112. package/tests/adr_0162_citation.mjs +184 -0
  113. package/tests/autobongos_cadence.mjs +74 -0
  114. package/tests/autobongos_loop.mjs +114 -0
  115. package/tests/autonomy_gauge.mjs +56 -0
  116. package/tests/consumer_layout_boot.mjs +363 -31
  117. package/tests/core_upgrade_door.mjs +40 -6
  118. package/tests/core_upgrade_runner.mjs +329 -5
  119. package/tests/credential_presence.mjs +153 -0
  120. package/tests/db_backup_nightly.mjs +21 -3
  121. package/tests/db_backup_offsite.mjs +81 -0
  122. package/tests/deploy_audit_gate.mjs +5 -2
  123. package/tests/deploy_page_projects.mjs +63 -2
  124. package/tests/dev_lib.mjs +2 -1
  125. package/tests/dev_windows.mjs +324 -0
  126. package/tests/env_knobs_neutral_prefix.mjs +203 -0
  127. package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
  128. package/tests/government_protected_surfaces.mjs +10 -0
  129. package/tests/grade_correlation_audit.mjs +159 -0
  130. package/tests/grade_engine.mjs +249 -0
  131. package/tests/grade_engine_replay.mjs +99 -0
  132. package/tests/grader_prompt_window_bound.mjs +132 -0
  133. package/tests/grader_unbacked_score.mjs +194 -0
  134. package/tests/hall_preview_scrub.mjs +214 -0
  135. package/tests/hall_records.mjs +85 -0
  136. package/tests/helpers.mjs +25 -0
  137. package/tests/instance_db_migrated_on_code_move.mjs +8 -1
  138. package/tests/integration_parked_ratchet.mjs +24 -1
  139. package/tests/plain_cards.mjs +1 -1
  140. package/tests/provisioning_public_refusal.mjs +195 -0
  141. package/tests/publish_status_branch.mjs +128 -0
  142. package/tests/redteam_patrol.mjs +1 -1
  143. package/tests/release_notes.mjs +327 -0
  144. package/tests/shell_stat_portability.mjs +49 -0
  145. package/tests/ship_land_conflict.mjs +181 -0
  146. package/tests/ship_land_red_pr.mjs +236 -0
  147. package/tests/ship_preflight.mjs +226 -3
  148. package/tests/start_env_guard.mjs +16 -0
  149. package/tests/sweep_app_account.mjs +15 -2
  150. package/tests/upgrade.mjs +20 -1
  151. package/tests/upgrade_migrate.mjs +195 -0
@@ -140,10 +140,25 @@ async function lookupInstanceRow(instanceDir, deps = {}) {
140
140
  } catch { return null; }
141
141
  }
142
142
 
143
+ // What upgrade.js's pin push says when lookupInstanceRow found NO row on a checkout wired
144
+ // with a read-only deploy key (task 1004065). PURE.
145
+ //
146
+ // That pairing is NOT the origin path the header above describes. The key says this is a
147
+ // hosted private project (ADR 0176), and its own database never has the provisioning row
148
+ // (ADR 0281) — so the owner's token cannot be looked up here, and the remote the push
149
+ // falls back to cannot write. Silence made that indistinguishable from a misconfiguration.
150
+ // A missing row anywhere else IS the origin path and stays quiet, which is why the caller
151
+ // only asks for this line when the key is present. Lives here rather than beside its
152
+ // caller because upgrade.js sits exactly at the oversized-file ratchet.
153
+ function missingRowNote(key, remote = 'origin') {
154
+ return ` • pin push: no provisioning row is readable from this database, so the owner's GitHub token cannot be looked up here — and ${remote} is the read-only deploy key at ${key}. A hosted project's own database never has that row (ADR 0281); the deploy door pushes the pin from the hub instead (task 1004065).`;
155
+ }
156
+
143
157
  module.exports = {
144
158
  instanceRepoRemote,
145
159
  instanceRepoRemotes,
146
160
  lookupInstanceRow,
161
+ missingRowNote,
147
162
  parseTargetRef,
148
163
  resolveOwnerGithubToken,
149
164
  };
@@ -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();
@@ -5,7 +5,7 @@
5
5
  // The patrol is the third enforcement layer (prevent → gate → PATROL): checks
6
6
  // too slow, too networked, or too history-wide for the merge gate, run weekly
7
7
  // by the .claude/scheduled-tasks/redteam-weekly routine. It is ADVISORY by
8
- // doctrine (ADR 0162 — verdicts never self-execute): findings are FILED as
8
+ // doctrine (ADR 0158 §2 — a verdict never self-executes): findings are FILED as
9
9
  // security reports for Archon adjudication, never turned into gates here.
10
10
  //
11
11
  // Checks (all deterministic — no model reads anything):
@@ -40,7 +40,7 @@
40
40
  // log. Instead --file preflights the session and, when there is none, follows the
41
41
  // main-audit.js --require-roster doctrine: report LOUDLY rather than green-and-
42
42
  // vacuous. The exit code says whether a finding was LOST, not whether findings
43
- // exist — findings are advisory (ADR 0162) and a patrol that goes red merely for
43
+ // exist — findings are advisory (ADR 0158 §2) and a patrol that goes red merely for
44
44
  // doing its job trains everyone to ignore it:
45
45
  //
46
46
  // ran + filed → 0
@@ -271,7 +271,7 @@ function buildClassReport(className, findings, notes = []) {
271
271
  target: `redteam-patrol/${className}`,
272
272
  severity: maxSeverity(findings),
273
273
  title: `[patrol] ${className}: ${findings.length} finding(s)`,
274
- description: `Weekly deterministic red-team patrol (task 1003128; advisory per ADR 0162 — adjudicate, then fix via ordinary tasks).`,
274
+ description: `Weekly deterministic red-team patrol (task 1003128; advisory per ADR 0158 §2 — adjudicate, then fix via ordinary tasks).`,
275
275
  repro_steps_md: body,
276
276
  };
277
277
  }
@@ -289,7 +289,7 @@ function stepSummary(md) {
289
289
  // that disposition deserves. Extracted so the honest-exit contract is a table a
290
290
  // test can enumerate rather than branching buried in main().
291
291
  //
292
- // The load-bearing row is the last one. Findings are ADVISORY (ADR 0162), so a
292
+ // The load-bearing row is the last one. Findings are ADVISORY (ADR 0158 §2), so a
293
293
  // run that finds three things and files them is a SUCCESS — going red merely for
294
294
  // doing its job is how a weekly lane becomes noise everyone filters. Red is
295
295
  // reserved for the single state where evidence EVAPORATES: findings exist,
@@ -0,0 +1,271 @@
1
+ 'use strict';
2
+
3
+ // scripts/gds/release-notes.js — what each core release brought in, as the summaries its
4
+ // ships wrote, generated when the core is PACKED and shipped inside the package as
5
+ // release-notes.json (task 1004218).
6
+ //
7
+ // WHY INSIDE THE PACKAGE. The Deploy page's "what this brings in" list used to be read out
8
+ // of a core git checkout on the machine asking (task 1004173), and no tenant box will ever
9
+ // have one: every project the wizard creates is a checkout of its OWN repo with the core
10
+ // vendored in at node_modules. The one machine that always has the full history is the one
11
+ // that builds the package — publish.yml checks out at fetch-depth 0 because package-core
12
+ // archives from git — so that is where the notes are written down, once, and every project
13
+ // that can download a core version can read what it carries.
14
+ //
15
+ // THREE PARTS, NO I/O OF ITS OWN. The PARSER (parseChangeRecords, moved here from
16
+ // provision-core-upgrade.js so there is exactly one), the GENERATOR (package-core.js hands
17
+ // it the ref's history), and the RANGE READER (the upgrade runner asks it for from→to).
18
+ // Git is injected, never required, so loading this costs package-core nothing.
19
+ //
20
+ // THE SHAPE. `{ "<version>": [ { id?, text }, … ] }`, ascending by version, one key per
21
+ // release — including a release whose merges were all housekeeping, which keeps an EMPTY
22
+ // list. That is the difference between "nothing landed" and "not covered": a range is
23
+ // answerable only when its target is a key, so a missing key can never be read as silence.
24
+
25
+ const CHANGE_TEXT_MAX = 400;
26
+
27
+ // The file's name at the package root, beside package.json.
28
+ const RELEASE_NOTES_FILE = 'release-notes.json';
29
+
30
+ // THE BOUND. The whole history to 1.19.1029 is ~400 KB pretty-printed (1,033 releases,
31
+ // 1,399 summaries, measured 2026-09-25), growing roughly 200 KB a month at 20–30 releases a
32
+ // day. Nothing needs trimming today; the bound exists so the file cannot grow unwatched.
33
+ // Past it, the OLDEST releases are dropped — the ranges an owner asks about are recent ones
34
+ // — and the reader then says those older ranges are not covered, rather than guessing.
35
+ const RELEASE_NOTES_MAX_BYTES = 1024 * 1024;
36
+
37
+ // A release commit's subject — the lane's own carrier AND a human release both begin this
38
+ // way. The same marker findCoreCheckout's probe uses to recognise core history.
39
+ const RELEASE_SUBJECT_RE = /^release: core (\d+\.\d+\.\d+) /;
40
+ const CORE_VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/;
41
+
42
+ // One record per commit: sha, parents, subject, body. `--topo-order` is load-bearing — see
43
+ // buildReleaseNotes for why the order releases are visited in must be topological.
44
+ const LOG_ARGS = ['log', '--topo-order', '--format=%H%x1f%P%x1f%s%x1f%b%x1e'];
45
+
46
+ /**
47
+ * The merge summaries in a `git log --merges --format=%s%x1f%b%x1e` dump. PURE.
48
+ *
49
+ * TWO MERGE SHAPES CARRY A SUMMARY AND THE SECOND IS THE COMMON ONE. A hand-landed ship
50
+ * writes `Merge <branch>: <summary>`; the CI auto-merge writes `Merge pull request #N
51
+ * from <owner>/<branch>` with `task <id>: <summary>` in the BODY. Measured over
52
+ * 1.19.881→1.19.972: 114 usable summaries, of which 98 were the second shape. A parser
53
+ * that read subjects only would have found eleven of them and quietly called that the
54
+ * release.
55
+ *
56
+ * EVERYTHING ELSE IS DROPPED, and the drops are the majority: `Merge origin/main into
57
+ * <branch> (pre-stage freshness…)`, `Merge remote-tracking branch…`, a regen chore. Those
58
+ * are the mechanics of landing work, not the work, and listing them would bury the
59
+ * eleven sentences the owner actually wants under a hundred they do not.
60
+ */
61
+ function parseChangeRecords(stdout) {
62
+ const out = [];
63
+ const seen = new Set();
64
+ for (const rec of String(stdout || '').split('\x1e')) {
65
+ const r = rec.trim();
66
+ if (!r) continue;
67
+ const i = r.indexOf('\x1f');
68
+ const subject = (i < 0 ? r : r.slice(0, i)).trim();
69
+ const body = i < 0 ? '' : r.slice(i + 1);
70
+ let text = null;
71
+ let id = null;
72
+ // The negative lookahead is what keeps the two housekeeping shapes out: both begin
73
+ // "Merge " and both would otherwise match a `<token>:` somewhere later in the line.
74
+ let m = /^Merge\s+(?!pull request\b|remote-tracking\b)([^\s:]+):\s*(\S.*)$/.exec(subject);
75
+ if (m) {
76
+ text = m[2];
77
+ } else {
78
+ const first = body.split('\n').map((l) => l.trim()).find(Boolean) || '';
79
+ m = /^task\s+(\d+):\s*(\S.*)$/.exec(first);
80
+ if (m) { id = m[1]; text = m[2]; }
81
+ }
82
+ if (!text) continue;
83
+ // It reaches a DOM, and a ship summary is occasionally a paragraph.
84
+ text = boundText(text);
85
+ if (seen.has(text)) continue; // a branch merged twice says its sentence once
86
+ seen.add(text);
87
+ out.push(id ? { id, text } : { text });
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function boundText(text) {
93
+ return text.length > CHANGE_TEXT_MAX ? `${text.slice(0, CHANGE_TEXT_MAX - 1)}…` : text;
94
+ }
95
+
96
+ /** Order two three-number core versions. PURE; both must already match CORE_VERSION_RE. */
97
+ function compareCoreVersions(a, b) {
98
+ const x = String(a).split('.').map(Number);
99
+ const y = String(b).split('.').map(Number);
100
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
101
+ return 0;
102
+ }
103
+
104
+ /** Split a LOG_ARGS dump into `{ sha, parents, subject, body }` records, in log order. PURE. */
105
+ function parseLogRecords(stdout) {
106
+ const out = [];
107
+ for (const rec of String(stdout || '').split('\x1e')) {
108
+ const r = rec.replace(/^\n+/, ''); // git terminates each record with a newline
109
+ if (!r.trim()) continue;
110
+ const [sha, parents, subject, ...body] = r.split('\x1f');
111
+ out.push({
112
+ sha: sha.trim(),
113
+ parents: String(parents || '').split(' ').filter(Boolean),
114
+ subject: subject || '',
115
+ body: body.join('\x1f'),
116
+ });
117
+ }
118
+ return out;
119
+ }
120
+
121
+ function uniqueByText(entries) {
122
+ const seen = new Set();
123
+ return entries.filter((e) => (seen.has(e.text) ? false : seen.add(e.text)));
124
+ }
125
+
126
+ /**
127
+ * What each release brought in, from a whole history. PURE.
128
+ *
129
+ * `commits` is every commit the packed ref reaches, NEWEST FIRST in topological order (what
130
+ * LOG_ARGS asks git for). Returns `{ notes, releases, tail }`.
131
+ *
132
+ * EXACTLY `git log --merges <previous release>..<release>`, FOR EVERY RELEASE AT ONCE — the
133
+ * range the checkout-based reader asks for one pair at a time. Splitting the log wherever a
134
+ * release subject appears would be simpler and is wrong: a PR branch's commits are not
135
+ * ordered against main's by the log, so a merge made on a branch before one release but
136
+ * landed after it would be credited to the earlier release. So each release, oldest first,
137
+ * claims everything it can reach that no earlier release reached. The visiting order has to
138
+ * be topological for that to be the previous-release range — hence `--topo-order`.
139
+ *
140
+ * THE UNRELEASED TAIL — commits the ref reaches that no release commit does — belongs to the
141
+ * version being packed when that version has no release commit of its own yet (a pack of a
142
+ * hand-declared version), and to nobody otherwise: on a work merge the tail is the NEXT
143
+ * release's, whose number does not exist yet.
144
+ */
145
+ function buildReleaseNotes(commits, { version = null } = {}) {
146
+ const bySha = new Map(commits.map((c) => [c.sha, c]));
147
+ const position = new Map(commits.map((c, i) => [c.sha, i]));
148
+ const claimed = new Set();
149
+ const claim = (tip) => {
150
+ const mine = [];
151
+ const stack = [tip];
152
+ while (stack.length) {
153
+ const sha = stack.pop();
154
+ if (claimed.has(sha) || !bySha.has(sha)) continue;
155
+ claimed.add(sha);
156
+ const c = bySha.get(sha);
157
+ mine.push(c);
158
+ for (const p of c.parents) if (!claimed.has(p)) stack.push(p);
159
+ }
160
+ return mine;
161
+ };
162
+ // Newest first within a release, as `git log` itself would list them.
163
+ const summarize = (segment) => parseChangeRecords(segment
164
+ .filter((c) => c.parents.length > 1)
165
+ .sort((a, b) => position.get(a.sha) - position.get(b.sha))
166
+ .map((c) => `${c.subject}\x1f${c.body}\x1e`)
167
+ .join(''));
168
+
169
+ const byVersion = new Map();
170
+ let releases = 0;
171
+ for (let i = commits.length - 1; i >= 0; i--) {
172
+ const m = RELEASE_SUBJECT_RE.exec(commits[i].subject);
173
+ if (!m) continue;
174
+ releases++;
175
+ // A version released twice keeps both halves; the second claims only what the first
176
+ // could not reach.
177
+ byVersion.set(m[1], uniqueByText([...(byVersion.get(m[1]) || []), ...summarize(claim(commits[i].sha))]));
178
+ }
179
+ const tail = commits.filter((c) => !claimed.has(c.sha));
180
+ if (version && CORE_VERSION_RE.test(version) && !byVersion.has(version)
181
+ && [...byVersion.keys()].every((v) => compareCoreVersions(v, version) < 0)) {
182
+ byVersion.set(version, summarize(tail));
183
+ }
184
+ const notes = {};
185
+ for (const v of [...byVersion.keys()].sort(compareCoreVersions)) notes[v] = byVersion.get(v);
186
+ return { notes, releases, tail: tail.length };
187
+ }
188
+
189
+ /**
190
+ * Read a ref's history through an injected `git(args) → stdout` and build its notes.
191
+ * Returns `{ notes, releases, tail }`, or `{ skipped }` saying why there are none — never
192
+ * a partial answer presented as a whole one.
193
+ */
194
+ function generateReleaseNotes({ git, ref, version }) {
195
+ let shallow;
196
+ try { shallow = String(git(['rev-parse', '--is-shallow-repository'])).trim() === 'true'; }
197
+ catch (e) { return { skipped: `could not ask git about this clone (${firstLine(e)})` }; }
198
+ // A depth-limited clone reaches a handful of commits, and notes built from it would
199
+ // credit the newest release with everything the clone happens to hold.
200
+ if (shallow) return { skipped: 'this is a shallow clone, so it carries no release history' };
201
+ let out;
202
+ try { out = git([...LOG_ARGS, ref]); }
203
+ catch (e) { return { skipped: `could not read the history of ${ref} (${firstLine(e)})` }; }
204
+ const built = buildReleaseNotes(parseLogRecords(out), { version });
205
+ if (!built.releases) return { skipped: 'this history holds no release commits' };
206
+ return built;
207
+ }
208
+
209
+ function firstLine(e) {
210
+ return String((e && e.message) || e || '').trim().split('\n')[0].slice(0, 160);
211
+ }
212
+
213
+ /** The file's bytes: the same pretty-printed JSON the package's other generated files use. */
214
+ function serializeReleaseNotes(notes) {
215
+ return Buffer.from(`${JSON.stringify(notes, null, 2)}\n`, 'utf8');
216
+ }
217
+
218
+ /**
219
+ * Hold the file under `maxBytes` by dropping the OLDEST releases. PURE.
220
+ * Returns `{ notes, dropped }`; the newest release is always kept.
221
+ */
222
+ function boundReleaseNotes(notes, maxBytes = RELEASE_NOTES_MAX_BYTES) {
223
+ const versions = Object.keys(notes);
224
+ const keep = (from) => Object.fromEntries(versions.slice(from).map((v) => [v, notes[v]]));
225
+ const fits = (from) => serializeReleaseNotes(keep(from)).length <= maxBytes;
226
+ if (versions.length <= 1 || fits(0)) return { notes, dropped: [] };
227
+ // The fewest drops that fit, by bisection — the size only shrinks as more are dropped.
228
+ let lo = 1;
229
+ let hi = versions.length - 1;
230
+ while (lo < hi) {
231
+ const mid = (lo + hi) >> 1;
232
+ if (fits(mid)) hi = mid; else lo = mid + 1;
233
+ }
234
+ return { notes: keep(lo), dropped: versions.slice(0, lo) };
235
+ }
236
+
237
+ /**
238
+ * The entries for every release in `(from, to]`, newest release first. PURE.
239
+ *
240
+ * Returns `{ entries }` or `{ uncovered }` — a phrase saying why these notes cannot answer,
241
+ * worded to follow "the release notes …". NEVER an empty list for a range the notes do not
242
+ * reach: an empty list reads as "nothing changed", which is exactly what a restart must not
243
+ * be sold as. They reach a range when they list its target and begin at or before its
244
+ * start: the oldest release they hold is where their knowledge begins.
245
+ *
246
+ * The file may have come off the network, so every entry is re-checked here, not trusted.
247
+ */
248
+ function notesBetween(notes, from, to) {
249
+ if (!notes || typeof notes !== 'object' || Array.isArray(notes)) return { uncovered: 'are not a list of releases' };
250
+ const versions = Object.keys(notes)
251
+ .filter((v) => CORE_VERSION_RE.test(v) && Array.isArray(notes[v]))
252
+ .sort(compareCoreVersions);
253
+ if (!versions.includes(to)) return { uncovered: `do not list core ${to}` };
254
+ if (compareCoreVersions(versions[0], from) > 0) return { uncovered: `begin at core ${versions[0]}, after ${from}` };
255
+ const inRange = versions.filter((v) => compareCoreVersions(v, from) > 0 && compareCoreVersions(v, to) <= 0);
256
+ const entries = [];
257
+ for (const v of inRange.reverse()) {
258
+ for (const e of notes[v]) {
259
+ if (!e || typeof e.text !== 'string' || !e.text.trim()) continue;
260
+ const text = boundText(e.text.trim());
261
+ entries.push(/^\d+$/.test(String(e.id || '')) ? { id: String(e.id), text } : { text });
262
+ }
263
+ }
264
+ return { entries: uniqueByText(entries) };
265
+ }
266
+
267
+ module.exports = {
268
+ RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES,
269
+ parseChangeRecords, parseLogRecords, buildReleaseNotes,
270
+ generateReleaseNotes, serializeReleaseNotes, boundReleaseNotes, notesBetween,
271
+ };
@@ -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',
@@ -43,7 +43,7 @@ const TASKS = [
43
43
  // ---- Phase A — decide + foundation (P5) ----
44
44
  { ref:'R01', kind:'decision', disc:'engineer', prio:5, est:150, crit:[C1,C3,C4], deps:[],
45
45
  title:'R01 · ADR: the Board Room government model',
46
- desc:`Record the government model as one decision so the schema has something to encode.\n\nMUST STATE: the board's TWO subjects (a Full Idea, and a change to the constitution itself) and why nothing else is votable; membership as a configured predicate resolved per request and uncached; the timed SITTING (absent votes are nulled, not blocking) and why that replaces quorum — the clock IS the deadlock exit; CONSENT rather than majority, with silence as consent and one reasoned objection returning the idea; a zero-vote close PASSING; the OPEN ballot; Discord as an outbound notification mirror that casts nothing; and the amendment rule — a constitutional change is decided under whatever rule the CURRENT constitution names, so flipping out of monarchy is a legal monarchy act.\n\nAlso record the two owner decisions taken against recommendation with their trade-offs intact: vote-karma (yes-clicking bias, accepted because item volume is too low to farm) and automatic window-open on grade pass (author loses timing control; concurrent windows become normal).\n\nGrounding: idea 1000738, idea 1000754, the Six Variables of Governance synthesis, ADR 0151 (permissions-as-atoms), ADR 0157, ADR 0162 (verdicts advisory), ADR 0174 (government/ranks/roles). ${SPEC}` },
46
+ desc:`Record the government model as one decision so the schema has something to encode.\n\nMUST STATE: the board's TWO subjects (a Full Idea, and a change to the constitution itself) and why nothing else is votable; membership as a configured predicate resolved per request and uncached; the timed SITTING (absent votes are nulled, not blocking) and why that replaces quorum — the clock IS the deadlock exit; CONSENT rather than majority, with silence as consent and one reasoned objection returning the idea; a zero-vote close PASSING; the OPEN ballot; Discord as an outbound notification mirror that casts nothing; and the amendment rule — a constitutional change is decided under whatever rule the CURRENT constitution names, so flipping out of monarchy is a legal monarchy act.\n\nAlso record the two owner decisions taken against recommendation with their trade-offs intact: vote-karma (yes-clicking bias, accepted because item volume is too low to farm) and automatic window-open on grade pass (author loses timing control; concurrent windows become normal).\n\nGrounding: idea 1000738, idea 1000754, the Six Variables of Governance synthesis, ADR 0151 (permissions-as-atoms), ADR 0157, ADR 0158 §2 (verdicts advisory), ADR 0162 (no human approval in the land path), ADR 0174 (government/ranks/roles). ${SPEC}` },
47
47
 
48
48
  { ref:'R02', kind:'decision', disc:'engineer', prio:5, est:90, crit:[C1], deps:['R01'],
49
49
  title:'R02 · The pass-event contract the ideator credit lane consumes',
@@ -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)