@bongos/core 1.19.318 → 1.19.320

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 (51) hide show
  1. package/.bongos-core.json +125 -40
  2. package/.claude/skills/merge-mode/SKILL.md +3 -1
  3. package/.devcontainer/init-firewall.sh +41 -5
  4. package/clients/bongos-client/examples/hello-world.mjs +11 -2
  5. package/docs/copy-inventory.md +20 -20
  6. package/docs/copy-registry.json +22 -22
  7. package/modules/hall-ui/public/builders.js +0 -1
  8. package/package-lock.json +2 -2
  9. package/package.json +1 -1
  10. package/scripts/gds/audit-replay.sh +20 -3
  11. package/scripts/gds/box.js +79 -86
  12. package/scripts/gds/db-backup-nightly.sh +17 -4
  13. package/scripts/gds/db-backup-offsite.sh +23 -6
  14. package/scripts/gds/discipline-modes.json +1 -1
  15. package/scripts/gds/doc-cli-guard.js +34 -2
  16. package/scripts/gds/fitness.js +8 -4
  17. package/scripts/gds/gds-db-backup.service +15 -4
  18. package/scripts/gds/gds-db-backup.timer +12 -1
  19. package/scripts/gds/gds-db-offsite.service +16 -5
  20. package/scripts/gds/gds-db-offsite.timer +12 -1
  21. package/scripts/gds/go-live.js +142 -48
  22. package/scripts/gds/instance-apex.js +28 -0
  23. package/scripts/gds/newcomer-floor.templates.json +2 -2
  24. package/scripts/gds/provision.js +7 -3
  25. package/scripts/gds/ship-deploy-target.js +388 -0
  26. package/scripts/gds/ship-finish.js +377 -0
  27. package/scripts/gds/ship-flow.js +814 -0
  28. package/scripts/gds/ship-git.js +100 -0
  29. package/scripts/gds/ship-grade-steps.js +376 -0
  30. package/scripts/gds/ship-grade.js +319 -0
  31. package/scripts/gds/ship-io.js +100 -0
  32. package/scripts/gds/ship-land.js +539 -0
  33. package/scripts/gds/ship-memory-sync.js +192 -0
  34. package/scripts/gds/ship-merge.js +297 -0
  35. package/scripts/gds/ship-preflight-steps.js +323 -0
  36. package/scripts/gds/ship-regen.js +286 -0
  37. package/scripts/gds/ship-resume.js +151 -0
  38. package/scripts/gds/ship-session-upload.js +216 -0
  39. package/scripts/gds/ship-state.js +73 -0
  40. package/scripts/gds/ship-surprise.js +84 -0
  41. package/scripts/gds/ship.js +105 -4195
  42. package/scripts/gds/update-channel.js +12 -0
  43. package/scripts/status-probe.mjs +30 -2
  44. package/src/module-api.js +5 -1
  45. package/tests/go_live.mjs +65 -32
  46. package/tests/helpers.mjs +13 -0
  47. package/tests/provision.mjs +7 -1
  48. package/tests/regrade_eligibility.mjs +43 -0
  49. package/tests/ship_assurance.mjs +4 -2
  50. package/tests/ship_publish_error.mjs +43 -0
  51. package/tests/update_channel.mjs +33 -0
@@ -0,0 +1,319 @@
1
+ // scripts/gds/ship-grade.js — grader baseline/diff/fingerprint, the subagent-grader runner and the smoke runners.
2
+ //
3
+ // Carved out of scripts/gds/ship.js by task 1003205 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original ship.js lines 1763-2049 unchanged. ship.js remains the facade that
6
+ // re-exports every name and owns the CLI entry point, so the ship contract and
7
+ // every flag are untouched, and the other scripts that import from it are too.
8
+
9
+ 'use strict';
10
+
11
+ const crypto = require('node:crypto');
12
+ const { spawn } = require('node:child_process');
13
+ const { cliClient } = require('./cli-lib');
14
+ const grader = require('../../modules/grading/grader');
15
+ const { resolveBuilderModel } = require('./ship-memory-sync.js');
16
+ const { argvOut, gitArgvOk, gitOk, shellOk } = require('./ship-git.js');
17
+ const { GRADER_DIFF_CHAR_CAP, GRADER_TIMEOUT_MS, REPO_ROOT, noteServerReward } = require('./ship-state.js');
18
+
19
+ // ---------- Step 1+2: local verification ----------
20
+
21
+ // V3.R18 (#209) → MAS Phase 1 #395: inputs for the subagent grader. The grader
22
+ // needs three things from the local working tree: the changed files list, the
23
+ // full unified diff against the baseline, and (for the audit-trail signals)
24
+ // the shortstat summary.
25
+ //
26
+ // task 1002422 (blocker 1000082): a builder-settable pre-task baseline. When a
27
+ // task's artifact reached main BEFORE the ship (a release task: package-core
28
+ // archives committed HEAD, so the version bump must merge first), merge-base(
29
+ // origin/main, HEAD) == HEAD → an EMPTY committed diff → the --allow-empty /
30
+ // skip-grade / Archon-only /confirm dead-end that NO sub-Archon builder can
31
+ // finish. --base <ref> pins the pre-task commit so the grader reads the REAL diff
32
+ // <base>...HEAD and the ship confirms via the rank-open graded path instead. Set
33
+ // once in main() after resolveBaseRef() validates it; null on a normal ship.
34
+ let baseOverrideSha = null;
35
+
36
+ // resolveBaseRef — validate + resolve a --base <ref> to a commit sha. Injection-
37
+ // safe: the ref reaches git as a literal argv element (execFile, no shell), never
38
+ // a string interpolation. Pure over injected git runners so the unit test drives
39
+ // it with fakes (no real repo). Returns { ok:true, sha } or { ok:false, reason }.
40
+ function resolveBaseRef(rawBase, { revParse, isAncestor } = {}) {
41
+ const ref = String(rawBase == null ? '' : rawBase).trim();
42
+ if (!ref) return { ok: false, reason: 'empty ref' };
43
+ // Conservative allowlist (defence-in-depth even though transport is argv): no
44
+ // whitespace, no leading dash (git would read it as a flag), no shell metachar.
45
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/~^-]*$/.test(ref)) {
46
+ return { ok: false, reason: `invalid ref syntax "${ref}"` };
47
+ }
48
+ const rp = revParse || ((r) => argvOut('git', ['rev-parse', '--verify', '--quiet', `${r}^{commit}`]));
49
+ const sha = rp(ref);
50
+ if (!sha) return { ok: false, reason: `"${ref}" is not a commit this repo knows` };
51
+ const anc = isAncestor || ((s) => gitArgvOk(['merge-base', '--is-ancestor', s, 'HEAD']));
52
+ if (!anc(sha)) {
53
+ return { ok: false, reason: `"${ref}" (${sha.slice(0, 9)}) is not an ancestor of HEAD — --base must be a commit your work built on top of` };
54
+ }
55
+ return { ok: true, sha };
56
+ }
57
+
58
+ // baseRefShipGuard — the SECURITY invariant behind --base. --base decouples the
59
+ // GRADED diff (<base>...HEAD) from what the merge leg lands (the whole branch:
60
+ // autoMerge runs `git merge <branch>`, bringing every commit ahead of main, not
61
+ // just <base>...HEAD). So if HEAD has commits AHEAD of origin/main, honoring
62
+ // --base would let the grader review only a SUBSET while the full branch merges —
63
+ // an unreviewed-code path onto main. --base is therefore ONLY valid for work
64
+ // ALREADY fully on main (its documented premise: "nothing left to merge"). Pure
65
+ // over the ahead-count string (git rev-list --count origin/main..HEAD) so it is
66
+ // unit-tested without a repo. '0' → allowed; anything else (or null) → refuse.
67
+ function baseRefShipGuard(aheadCount) {
68
+ if (aheadCount === '0') return { ok: true };
69
+ const n = aheadCount == null ? 'an unknown number of' : aheadCount;
70
+ return { ok: false, reason: `HEAD is ${n} commit(s) ahead of origin/main — --base is only for work already merged to main` };
71
+ }
72
+
73
+ // applyBaseOverride — the SINGLE source of truth for --base validation, shared by
74
+ // shipMain + regradeMain so the security guard can never diverge between entry
75
+ // points. Resolves the ref, enforces the 0-ahead invariant, and sets the module-
76
+ // level baseOverrideSha on success. Returns { ok:true, sha } | { ok:false, reason }
77
+ // (the caller prints + exits). runGit is injectable for tests.
78
+ function applyBaseOverride(baseRefRaw, { runGit = gitOk } = {}) {
79
+ const r = resolveBaseRef(baseRefRaw);
80
+ if (!r.ok) return { ok: false, reason: `--base ${baseRefRaw} is unusable (${r.reason})` };
81
+ // Freshen origin/main (best-effort; a stale ref only OVER-counts → errs to refuse).
82
+ runGit('git fetch origin main --quiet');
83
+ const guard = baseRefShipGuard(runGit('git rev-list --count origin/main..HEAD'));
84
+ if (!guard.ok) return { ok: false, reason: guard.reason };
85
+ baseOverrideSha = r.sha;
86
+ return { ok: true, sha: r.sha };
87
+ }
88
+
89
+ // #536: the grader's baseline is computed INDEPENDENTLY of touches[] — always
90
+ // from merge-base, never gated on the declared scope. (Historically the
91
+ // touches-gated drift scanner returned a null baseline for empty touches[],
92
+ // which once blinded the grader to a fully-committed diff and hard-FAILed
93
+ // empty-touches newcomer ships; the scanner is gone as of task 879, but the
94
+ // grader keeping its own merge-base baseline remains the correct invariant.)
95
+ // baseSha (task 1002422): a validated --base override wins over merge-base, so a
96
+ // task whose commits already landed on main still grades its real diff instead of
97
+ // seeing an empty one.
98
+ function computeGraderBaseline(claim, baseSha = baseOverrideSha) {
99
+ if (baseSha) return baseSha;
100
+ return (
101
+ gitOk('git merge-base origin/main HEAD') ||
102
+ gitOk('git merge-base main HEAD') ||
103
+ (claim && claim.head_sha) ||
104
+ null
105
+ );
106
+ }
107
+
108
+ function computeChangedFiles(baseline) {
109
+ if (!baseline) return [];
110
+ const out = gitOk(`git diff --name-only ${baseline}...HEAD`);
111
+ return out ? out.split('\n').map((s) => s.trim()).filter(Boolean) : [];
112
+ }
113
+
114
+ function computeDiffText(baseline) {
115
+ if (!baseline) return '';
116
+ // -M to track renames; --no-color so the subagent gets clean diff lines.
117
+ const diff = gitOk(`git diff --no-color -M ${baseline}...HEAD`);
118
+ if (!diff) return '';
119
+ if (diff.length > GRADER_DIFF_CHAR_CAP) {
120
+ const head = diff.slice(0, GRADER_DIFF_CHAR_CAP);
121
+ return `${head}\n\n[diff truncated — original was ${diff.length} chars, capped at ${GRADER_DIFF_CHAR_CAP}. The subagent can Read any file in its post-resolve state for the full picture.]`;
122
+ }
123
+ return diff;
124
+ }
125
+
126
+ function computeDiffShortstat(baseline) {
127
+ if (!baseline) return null;
128
+ const summary = gitOk(`git diff --shortstat ${baseline}...HEAD`);
129
+ if (!summary) return { files_changed: 0, lines_added: 0, lines_removed: 0 };
130
+ const files = /(\d+)\s+files?\s+changed/.exec(summary);
131
+ const ins = /(\d+)\s+insertions?\(\+\)/.exec(summary);
132
+ const del = /(\d+)\s+deletions?\(-\)/.exec(summary);
133
+ return {
134
+ files_changed: files ? Number(files[1]) : 0,
135
+ lines_added: ins ? Number(ins[1]) : 0,
136
+ lines_removed: del ? Number(del[1]) : 0,
137
+ };
138
+ }
139
+
140
+ // ---------- V4.R08 — diff fingerprint + grade reuse ----------
141
+
142
+ // SHA-256 of the raw diff string. Stable identifier for "has the committed
143
+ // diff changed?" — used as the key to skip re-running the adversarial panel
144
+ // when the diff is byte-for-byte identical to the last PASS.
145
+ function diffHash(rawDiff) {
146
+ return crypto.createHash('sha256').update(rawDiff || '').digest('hex');
147
+ }
148
+
149
+ // Compute the hash of the full (untruncated) diff against baseline. Returns
150
+ // null when baseline is unavailable so callers can treat null as "unknown".
151
+ function computeRawDiffHash(baseline) {
152
+ if (!baseline) return null;
153
+ const raw = gitOk(`git diff --no-color -M ${baseline}...HEAD`);
154
+ return diffHash(raw || '');
155
+ }
156
+
157
+ // Pure predicate: should we reuse the stored grade instead of running the
158
+ // panel again? True when a PASS grade exists for the exact same diff hash.
159
+ // A null storedGrade, a non-PASS grade, or a hash mismatch all return false.
160
+ function shouldReuseGrade(storedGrade, currentDiffHash) {
161
+ if (!storedGrade || !storedGrade.passed) return false;
162
+ if (!storedGrade.diff_hash || !currentDiffHash) return false;
163
+ return storedGrade.diff_hash === currentDiffHash;
164
+ }
165
+
166
+ // task 1002660: task_grades has no issues column — the panel's deduped
167
+ // issues[] survive only inside signals.workers[].issues. Rebuild them (same
168
+ // (file, summary) dedupe as aggregatePanel) so the reuse re-POST carries the
169
+ // substance the original PASS carried. Before this, the reused body posted
170
+ // issues: undefined, so every reused PASS with findings read as a zero-issue
171
+ // pass server-side and tripped the SR-9 plausibility guard into
172
+ // manual-confirm — 0/106 successful uses, the feature defeated itself.
173
+ function reconstructPanelIssues(signals) {
174
+ const out = [];
175
+ const seen = new Set();
176
+ for (const w of (Array.isArray(signals?.workers) ? signals.workers : [])) {
177
+ for (const it of (Array.isArray(w?.issues) ? w.issues : [])) {
178
+ if (!it || typeof it !== 'object') continue;
179
+ const key = `${it.file || ''}|${it.summary || ''}`;
180
+ if (seen.has(key)) continue;
181
+ seen.add(key);
182
+ out.push(it);
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+
188
+ // --- Subagent grader runner ---
189
+ // Wraps grader.gradeSubagent so ship.js callers see a single function with
190
+ // progress logging. MAS Phase 2.0 #410: gradeSubagent now runs the WORKER
191
+ // PANEL (Narc + Quality; Hacker / Efficiency Monger added in 2.1/2.2) in
192
+ // parallel. Each worker is a specialized prompt with its own verdict; the
193
+ // panel gate is unanimous PASS — any 'fail' blocks, any 'concerns' falls
194
+ // through to manual-confirm.
195
+ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: precomputedChanged, priorRound = null }) {
196
+ // Reuse the caller's changed-files list when provided (the empty-diff guard
197
+ // computes it just before calling us) so we don't run `git diff --name-only`
198
+ // twice per ship (#513).
199
+ const changedFiles = precomputedChanged || computeChangedFiles(baseline);
200
+ const diff = computeDiffText(baseline);
201
+ const shortstat = computeDiffShortstat(baseline);
202
+ // SEC #863: feed the grader the builder's LIVE rank so the deterministic
203
+ // permission-path pre-pass can hard-fail a sub-Metic diff that touches the
204
+ // rank/permission/ship-grade/deploy machinery. Best-effort read; if it fails
205
+ // the pre-pass fails OPEN (the pre-push hook + post-push main audit are the
206
+ // fail-closed layers).
207
+ let builderRank;
208
+ try {
209
+ const meRes = noteServerReward(await (await cliClient()).me.getMe());
210
+ if (meRes.ok) builderRank = meRes.data?.builder?.rank;
211
+ } catch (_) { /* unknown rank → pre-pass fails open; audit + hook still gate */ }
212
+ const runOpts = {
213
+ timeoutMs: GRADER_TIMEOUT_MS,
214
+ cwd: process.cwd(), // grade the FEATURE worktree, not REPO_ROOT
215
+ };
216
+ // task 1002659: the real session model (transcript-derived) drives the
217
+ // cross-model grader pick; null → the grader assumes, as before.
218
+ const builderModel = resolveBuilderModel();
219
+ const t0 = Date.now();
220
+ let result;
221
+ // #513: a small copy/docs-only diff gets a SINGLE generalist grader shot
222
+ // rather than the full adversarial panel — "lighter, not skipped". The 1-shot
223
+ // still returns ff+cq+economy+confidence so the server gate applies; the
224
+ // deterministic scanner + smoke + route-rank checks ran regardless. Fixes the
225
+ // inefficiency of running a 4-worker panel on a +1/-1 copy tweak.
226
+ if (grader.isTrivialDiff({ changedFiles, diffStats: shortstat })) {
227
+ const totalLines = (shortstat.lines_added || 0) + (shortstat.lines_removed || 0);
228
+ console.log(` subagent grader ${grader.VERSION}: TRIVIAL diff (${shortstat.files_changed} file(s), ${totalLines} line(s), copy/docs only) → single generalist shot, model=${grader.selectGraderModel({ builderModel })} — instead of the ${grader.WORKERS.DEFAULT_PANEL.length}-worker panel (#513)`);
229
+ console.log(` gate: the shot must return functional_fidelity>=${grader.THRESHOLD} AND code_quality>=${grader.THRESHOLD} AND confidence>=0.4`);
230
+ result = await grader.gradeSubagentV3MultiShot({
231
+ task,
232
+ valueSummary,
233
+ diff,
234
+ changedFiles,
235
+ diffStats: shortstat,
236
+ shots: 1,
237
+ builderModel, // task 1002659: real session model → cross-model grader pick
238
+ runOpts,
239
+ });
240
+ // task 1002660: the single generalist shot has no Hacker or Efficiency
241
+ // lens — acceptable for genuinely inert copy/docs files, but never
242
+ // silently. The marker makes the dropped lenses queryable by /grade-audit
243
+ // (same pattern as implausible_pass_waived, ADR 0164).
244
+ result.signals = {
245
+ ...(result.signals || {}),
246
+ trivial_path: true,
247
+ lenses_dropped: ['hacker', 'efficiency'],
248
+ };
249
+ } else {
250
+ const panel = grader.WORKERS.DEFAULT_PANEL;
251
+ console.log(` subagent grader ${grader.VERSION}: panel of ${panel.length} workers (${panel.join(', ')}), model=${grader.selectGraderModel({ builderModel })} (builder ${builderModel || `${grader.ASSUMED_BUILDER_MODEL} assumed`}), changed_files=${changedFiles.length}, diff_chars=${diff.length}, per-worker timeout=${GRADER_TIMEOUT_MS}ms`);
252
+ console.log(` gate: every worker must return verdict='pass'; 'concerns' → manual-confirm fall-through; 'fail' blocks`);
253
+ if (priorRound) {
254
+ console.log(` delta re-grade (task 1002662): prior round's findings + builder response ride along, fenced; answered findings can only re-veto with NEW evidence`);
255
+ }
256
+ result = await grader.gradeSubagent({
257
+ task,
258
+ valueSummary,
259
+ diff,
260
+ changedFiles,
261
+ diffStats: shortstat, // V3.R92 #353: feed diff size into the economy axis
262
+ builderRank, // SEC #863: drives the permission-path pre-pass
263
+ builderModel, // task 1002659: real session model → cross-model grader pick
264
+ priorRound, // task 1002662: delta-aware re-grade context (panel path only)
265
+ runOpts,
266
+ });
267
+ }
268
+ const wallMs = Date.now() - t0;
269
+ // Decorate signals with the local shortstat — useful in audit-trail.
270
+ // graded_head_sha (task 1002662): record WHAT commit this grade judged, so a
271
+ // later --regrade can compute the interim fix diff (prior graded HEAD → now)
272
+ // for the delta-aware prior-round payload.
273
+ result.signals = {
274
+ ...(result.signals || {}),
275
+ diff_stats: shortstat,
276
+ wall_ms: wallMs,
277
+ changed_files_count: changedFiles.length,
278
+ graded_head_sha: gitOk('git rev-parse HEAD') || null,
279
+ };
280
+ return result;
281
+ }
282
+
283
+ function runSmoke() {
284
+ // `cwd: REPO_ROOT` + a relative script path is robust to spaces or other
285
+ // shell metacharacters that may appear in the absolute repo path on some
286
+ // machines.
287
+ const okPms = shellOk(`"${process.execPath}" scripts/gds/smoke-gds.js`, { cwd: REPO_ROOT });
288
+ return { ran: true, clean: okPms };
289
+ }
290
+
291
+ // task 1002660: the async twin of runSmoke for the PRE-RESOLVE gate only. The
292
+ // sync runSmoke blocks the event loop (execSync), which would freeze the
293
+ // overlapped grade-panel promise mid-flight; awaiting this one keeps the loop
294
+ // free so the panel's subprocess callbacks progress while smoke runs. The
295
+ // post-merge smoke call keeps the sync form (nothing overlaps there).
296
+ function runSmokeAsync() {
297
+ return new Promise((resolve) => {
298
+ const child = spawn(process.execPath, ['scripts/gds/smoke-gds.js'], {
299
+ cwd: REPO_ROOT, stdio: 'inherit', windowsHide: true,
300
+ });
301
+ child.on('error', () => resolve({ ran: true, clean: false }));
302
+ child.on('close', (code) => resolve({ ran: true, clean: code === 0 }));
303
+ });
304
+ }
305
+
306
+ module.exports = {
307
+ applyBaseOverride,
308
+ baseRefShipGuard,
309
+ computeChangedFiles,
310
+ computeGraderBaseline,
311
+ computeRawDiffHash,
312
+ diffHash,
313
+ reconstructPanelIssues,
314
+ resolveBaseRef,
315
+ runSmoke,
316
+ runSmokeAsync,
317
+ runSubagentGrader,
318
+ shouldReuseGrade,
319
+ };
@@ -0,0 +1,100 @@
1
+ // scripts/gds/ship-io.js — API-error normalization and the small terminal effects (bell, post-land outcome).
2
+ //
3
+ // Carved out of scripts/gds/ship.js by task 1003205 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original ship.js lines 2928-3007 unchanged. ship.js remains the facade that
6
+ // re-exports every name and owns the CLI entry point, so the ship contract and
7
+ // every flag are untouched, and the other scripts that import from it are too.
8
+
9
+ 'use strict';
10
+
11
+ const os = require('node:os');
12
+ const { playSound } = require('./ship-state.js');
13
+
14
+ function playShipBell() {
15
+ let shipWav = 'ship';
16
+ try {
17
+ const prefsPath = require('path').join(os.homedir(), '.config', 'otb', 'sound-prefs.json');
18
+ const raw = require('fs').readFileSync(prefsPath, 'utf8');
19
+ const parsed = JSON.parse(raw);
20
+ const ev = parsed && parsed.event_sounds && parsed.event_sounds['task-shipped'];
21
+ if (typeof ev === 'string') shipWav = ev;
22
+ } catch (_) { /* fall through to default */ }
23
+ playSound(shipWav);
24
+ }
25
+
26
+ // task 1002809: normalize an API error body across BOTH wire shapes — the live
27
+ // ADR 0116 (R01) envelope `{ error: { code, message } }` and the legacy flat
28
+ // `{ error: 'code' }`. Same normalization as claim.js:formatClaimFailure and
29
+ // cli-lib:isBoxScope; pure + exported for tests/ship_post_land_verify.mjs.
30
+ function normalizeApiError(data) {
31
+ const errRaw = data ? data.error : undefined;
32
+ const isEnvelope = errRaw !== null && typeof errRaw === 'object';
33
+ return {
34
+ code: isEnvelope ? errRaw.code : errRaw,
35
+ message: isEnvelope ? errRaw.message : undefined,
36
+ // task 1002789: `details` is where the envelope carries the structured
37
+ // payload the flat shape used to put at the TOP level (claimWorktree,
38
+ // step/reason, floors). A site that reads `data.<field>` directly is
39
+ // reading a field that moved. Fall back to the body so a legacy flat
40
+ // response still resolves.
41
+ details: (isEnvelope && errRaw.details && typeof errRaw.details === 'object')
42
+ ? errRaw.details
43
+ : (data && typeof data === 'object' ? data : {}),
44
+ };
45
+ }
46
+
47
+ // task 1002789: the ONE renderer for a failed API call on the ship path.
48
+ // Every generic bail site used to hand the raw body to console.error (or
49
+ // interpolate `data.error` into a template, which stringifies the ADR 0116
50
+ // envelope object to the literal "[object Object]"). Both hide a fully
51
+ // diagnosable server error: console.error's util.inspect stops at depth 2, so
52
+ // the `details` that names WHICH git step failed prints as "[Object]".
53
+ // Collapse the body to one readable line instead, whichever shape it is.
54
+ // Pure + exported for tests/ship_error_shape.mjs.
55
+ function apiErrorLine(data, status) {
56
+ const { code, message, details } = normalizeApiError(data);
57
+ const head = code
58
+ ? (message && message !== code ? `${code}: ${message}` : code)
59
+ : (message || (status ? `HTTP ${status}` : 'unknown error'));
60
+ // Only the small scalar fields — a details blob can carry a whole bundle.
61
+ const extras = Object.entries(details || {})
62
+ .filter(([k, v]) => k !== 'code' && k !== 'message' && k !== 'error'
63
+ && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'))
64
+ .map(([k, v]) => `${k}: ${String(v).replace(/\s+/g, ' ').trim()}`);
65
+ const line = extras.length ? `${head} (${extras.join('; ')})` : head;
66
+ return status ? `${line} [HTTP ${status}]` : line;
67
+ }
68
+
69
+ // task 1002809: classify a FAILED post-land POST /tasks/<id>/ship. The merge
70
+ // already succeeded, so the server may simply have landed the task first
71
+ // (reconcile-on-read — task 1049 — or the ~5-min reconciler racing this call).
72
+ // The old check compared `data.error === 'task_not_confirmed'` — dead against
73
+ // the envelope shape, so the already-landed SUCCESS rendered as a failure with
74
+ // an "[object Object]" reason (the HANDOFF-apex-ui "400 after a successful
75
+ // land" gotcha). liveStatus is the task's status from a verify GET (null when
76
+ // that GET itself failed): 'shipped' is authoritative success whatever the
77
+ // error said; task_not_confirmed with no live status keeps the task-1049
78
+ // benefit of the doubt; any KNOWN non-shipped status stays a loud failure.
79
+ // Pure + exported for tests/ship_post_land_verify.mjs.
80
+ function postLandShipOutcome({ code, liveStatus }) {
81
+ if (liveStatus === 'shipped') {
82
+ return { alreadyShipped: true, why: 'the server landed it first — the failed ship call lost that race, nothing to fix' };
83
+ }
84
+ if (liveStatus == null && code === 'task_not_confirmed') {
85
+ return { alreadyShipped: true, why: 'the server reconciled it from the merged PR' };
86
+ }
87
+ return { alreadyShipped: false };
88
+ }
89
+
90
+ // Step 3 of the lifecycle: confirmed → shipped. Merge the branch into main,
91
+ // deploy, flip the task to shipped, and fire the Discord broadcast (only when
92
+ // the change actually deploys — #94 / ADR 0032; best-effort). Side-effecting;
93
+ // the early returns are control flow only.
94
+
95
+ module.exports = {
96
+ apiErrorLine,
97
+ normalizeApiError,
98
+ playShipBell,
99
+ postLandShipOutcome,
100
+ };