@bongos/core 1.19.319 → 1.19.321

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/dev-box/box-access.js +20 -0
  8. package/modules/hall-ui/public/builders.js +0 -1
  9. package/package-lock.json +2 -2
  10. package/package.json +1 -1
  11. package/scripts/gds/audit-replay.sh +20 -3
  12. package/scripts/gds/box.js +79 -86
  13. package/scripts/gds/db-backup-nightly.sh +17 -4
  14. package/scripts/gds/db-backup-offsite.sh +23 -6
  15. package/scripts/gds/discipline-modes.json +1 -1
  16. package/scripts/gds/doc-cli-guard.js +34 -2
  17. package/scripts/gds/fitness-ratchets.js +54 -6
  18. package/scripts/gds/fitness.js +8 -4
  19. package/scripts/gds/gds-db-backup.service +15 -4
  20. package/scripts/gds/gds-db-backup.timer +12 -1
  21. package/scripts/gds/gds-db-offsite.service +16 -5
  22. package/scripts/gds/gds-db-offsite.timer +12 -1
  23. package/scripts/gds/instance-apex.js +28 -0
  24. package/scripts/gds/newcomer-floor.templates.json +2 -2
  25. package/scripts/gds/provision.js +7 -3
  26. package/scripts/gds/ship-deploy-target.js +388 -0
  27. package/scripts/gds/ship-finish.js +377 -0
  28. package/scripts/gds/ship-flow.js +814 -0
  29. package/scripts/gds/ship-git.js +100 -0
  30. package/scripts/gds/ship-grade-steps.js +376 -0
  31. package/scripts/gds/ship-grade.js +319 -0
  32. package/scripts/gds/ship-io.js +100 -0
  33. package/scripts/gds/ship-land.js +539 -0
  34. package/scripts/gds/ship-memory-sync.js +192 -0
  35. package/scripts/gds/ship-merge.js +297 -0
  36. package/scripts/gds/ship-preflight-steps.js +323 -0
  37. package/scripts/gds/ship-regen.js +286 -0
  38. package/scripts/gds/ship-resume.js +151 -0
  39. package/scripts/gds/ship-session-upload.js +216 -0
  40. package/scripts/gds/ship-state.js +73 -0
  41. package/scripts/gds/ship-surprise.js +84 -0
  42. package/scripts/gds/ship.js +105 -4195
  43. package/scripts/status-probe.mjs +30 -2
  44. package/src/module-api.js +5 -1
  45. package/tests/box_access.mjs +46 -0
  46. package/tests/fitness_ratchets.mjs +82 -0
  47. package/tests/helpers.mjs +13 -0
  48. package/tests/provision.mjs +7 -1
  49. package/tests/regrade_eligibility.mjs +43 -0
  50. package/tests/ship_assurance.mjs +4 -2
  51. package/tests/ship_publish_error.mjs +43 -0
@@ -34,10 +34,30 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
34
34
  import { existsSync } from 'node:fs';
35
35
  import path from 'node:path';
36
36
  import { pathToFileURL } from 'node:url';
37
+ import { createRequire } from 'node:module';
38
+
39
+ // Target + User-Agent come from the INSTANCE's own branding pack (task 1002757).
40
+ // Both were founder literals, so another instance's uptime CI probed the founder
41
+ // host and reported ITS health as their own. No literal fallback for the target:
42
+ // a probe with nothing to probe must say so, not silently watch someone else.
43
+ const requireCjs = createRequire(import.meta.url);
44
+ const brandingSafe = () => { try { return requireCjs('../src/branding'); } catch { return null; } };
45
+ const defaultTarget = () => {
46
+ const b = brandingSafe();
47
+ try {
48
+ const origin = b && b.branding().domains?.publicOrigin;
49
+ return origin ? `${String(origin).replace(/\/+$/, '')}/healthz` : null;
50
+ } catch { return null; }
51
+ };
52
+ const defaultUserAgent = () => {
53
+ const b = brandingSafe();
54
+ try { return `${b.userAgent().split('/')[0]}-status-probe/1.0 (+github-actions)`; }
55
+ catch { return 'cloud-bongos-status-probe/1.0 (+github-actions)'; }
56
+ };
37
57
 
38
58
  const DEFAULTS = {
39
59
  dir: '.',
40
- target: 'https://amazonprimea.com/healthz',
60
+ target: defaultTarget(),
41
61
  attempts: 3,
42
62
  'timeout-ms': 15000,
43
63
  'gap-ms': 10000,
@@ -70,7 +90,7 @@ async function attempt(target, timeoutMs) {
70
90
  const res = await fetch(target, {
71
91
  signal: ac.signal,
72
92
  redirect: 'manual', // a 200 must come from the origin, not a redirect chain
73
- headers: { 'User-Agent': 'amazonprimea-status-probe/1.0 (+github-actions)' },
93
+ headers: { 'User-Agent': defaultUserAgent() },
74
94
  });
75
95
  return { ok: res.status === 200, httpStatus: res.status, latencyMs: Date.now() - start };
76
96
  } catch (_) {
@@ -125,6 +145,14 @@ async function main() {
125
145
  const historyFile = path.join(dir, 'history.jsonl');
126
146
  const statusFile = path.join(dir, 'status.json');
127
147
 
148
+ // No target means the branding pack names no public origin and none was passed
149
+ // (task 1002757 — there is deliberately no founder-host fallback to probe).
150
+ if (!args.target) {
151
+ console.error('status-probe: no target. Pass --target <url>, or give this instance a domains.publicOrigin in config/branding.json.');
152
+ process.exitCode = 2;
153
+ return;
154
+ }
155
+
128
156
  const result = await probe({
129
157
  target: args.target,
130
158
  attempts: args.attempts,
package/src/module-api.js CHANGED
@@ -1065,8 +1065,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1065
1065
  // landed since 1.19.317 with no explicit bump. run 32669798745. (task 1002620)
1066
1066
  // 1.19.319 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1067
1067
  // landed since 1.19.318 with no explicit bump. run 32670195088. (task 1002620)
1068
+ // 1.19.320 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1069
+ // landed since 1.19.319 with no explicit bump. run 32678147546. (task 1002620)
1070
+ // 1.19.321 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1071
+ // landed since 1.19.320 with no explicit bump. run 32679324013. (task 1002620)
1068
1072
  // ---------------------------------------------------------------------------
1069
- const CORE_VERSION = '1.19.319'; // CI auto-patch carrier (ADR 0161); see changelog above
1073
+ const CORE_VERSION = '1.19.321'; // CI auto-patch carrier (ADR 0161); see changelog above
1070
1074
 
1071
1075
  // A namespaced logger so a module's log lines are attributable + consistent.
1072
1076
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -368,5 +368,51 @@ t('a DENIED decision projects to null (the route returns 403, never a scope body
368
368
  assert.equal(access.scopeMetadata(), null, 'no args → null');
369
369
  });
370
370
 
371
+ // ---------- task 1003227: config/ must be in the box's BASE slice ----------
372
+ //
373
+ // Without it a box cannot run its own quality gate: fitness.js compares against
374
+ // config/fitness-baselines.json, and with the file absent the ratchet used to report
375
+ // PASS (a planted 5,000-line + 94-cycle regression sailed through). So a boxed builder
376
+ // shipped believing they were clean and learned otherwise from red CI, with nothing on
377
+ // the box to reproduce it. Precedent for the same class: ADR 0174 put
378
+ // config/government.json in the `government` globs, config/ was not in BASE, and task
379
+ // 1003018 was RELEASED because its box could never see its own deliverable.
380
+
381
+ t('1003227 BASE includes config/ so a box can read its own fitness baselines', () => {
382
+ const spec = access.decideSourceAccess({
383
+ rank: 'archon', status: 'active', scopeKeys: [], hasActiveClaims: false, moduleScopeMap: {},
384
+ });
385
+ assert.equal(spec.scope, 'base');
386
+ assert.ok(spec.spec.sparsePaths.includes('config'),
387
+ `base scope must carry config/; got ${spec.spec.sparsePaths.join(',')}`);
388
+ });
389
+
390
+ t('1003227 a TASK-scoped box carries config/ too (the common case — a claim is held)', () => {
391
+ // The REAL scope map, not a hand-rolled object — the shape matters here.
392
+ const scopeMap = require('../src/bongos/module-scope-map.js');
393
+ const spec = access.decideSourceAccess({
394
+ rank: 'xenos', status: 'active', scopeKeys: ['economy'], hasActiveClaims: true,
395
+ moduleScopeMap: scopeMap,
396
+ });
397
+ assert.equal(spec.scope, 'task');
398
+ assert.ok(spec.spec.sparsePaths.includes('config'), 'task scope must carry config/ as well');
399
+ assert.ok(spec.spec.sparsePaths.includes('modules/economy'),
400
+ `and still layer the claimed module on top; got ${spec.spec.sparsePaths.join(',')}`);
401
+ });
402
+
403
+ t('1003227 config/ is a cone-mode DIRECTORY entry (a file path aborts the whole set)', () => {
404
+ // ADR 0148: `git sparse-checkout set` rejects a file path, which kills the entire
405
+ // checkout — so every BASE entry must be a bare directory, no trailing slash.
406
+ for (const p of access.BASE_SPARSE_PATHS) {
407
+ assert.ok(!p.endsWith('/'), `${p} must not carry a trailing slash`);
408
+ // A dotted DIRECTORY (.claude, .devcontainer) is fine; what cone mode rejects is
409
+ // a file, so only test for an extension on the FINAL segment of a non-dotfile.
410
+ const last = p.split('/').pop();
411
+ if (!last.startsWith('.')) {
412
+ assert.ok(!/\.[a-z0-9]+$/i.test(last), `${p} looks like a FILE — cone mode would abort the set`);
413
+ }
414
+ }
415
+ });
416
+
371
417
  console.log(`\nbox_access.mjs: ${passed} passed, ${failed} failed`);
372
418
  process.exit(failed === 0 ? 0 : 1);
@@ -89,3 +89,85 @@ test('the committed baseline file parses and covers every hermetic metric', () =
89
89
  assert.ok(Number.isInteger(b.metrics[key]), `baseline missing ${key}`);
90
90
  }
91
91
  });
92
+
93
+ // ---------- task 1003227: the gate must FAIL CLOSED on an unusable baseline ----------
94
+ //
95
+ // readBaselines used to `catch { return {} }`. An empty baseline map leaves every
96
+ // `floor === undefined`, which compareToBaselines classifies as a WARNING — so a
97
+ // checkout with no config/ reported the ratchet PASSED. Measured before the fix: a
98
+ // planted 5,000-line + 94-cycle regression produced ZERO violations. That was not a
99
+ // hypothetical checkout — config/ is absent from every dev box's sparse slice, so the
100
+ // builders who most needed the gate were the ones silently exempt from it.
101
+ //
102
+ // checkQualityRatchets() reads the real repo, so these tests drive compareToBaselines
103
+ // (pure) for the metric-level rules and exercise the file-level rule by pointing at a
104
+ // baseline that does not exist.
105
+
106
+ test('1003227 an ABSENT baseline file makes the gate hard-fail, not pass', async () => {
107
+ const fs = require('node:fs');
108
+ const real = rat.BASELINE_PATH;
109
+ const backup = `${real}.1003227-test-backup`;
110
+ const had = fs.existsSync(real);
111
+ if (had) fs.renameSync(real, backup);
112
+ try {
113
+ const r = rat.checkQualityRatchets();
114
+ assert.equal(r.hardFail, true, 'a gate that cannot read its floors must not report a pass');
115
+ assert.ok(r.violations.length > 0, 'and it must say so as a VIOLATION, not a warning');
116
+ assert.match(r.violations[0], /MISSING/, 'the first violation names the real cause');
117
+ assert.match(r.violations[0], /fails CLOSED/i, 'and states the posture explicitly');
118
+ } finally {
119
+ if (had) fs.renameSync(backup, real);
120
+ }
121
+ });
122
+
123
+ test('1003227 a MALFORMED baseline file also hard-fails', async () => {
124
+ const fs = require('node:fs');
125
+ const real = rat.BASELINE_PATH;
126
+ const backup = `${real}.1003227-test-backup2`;
127
+ const had = fs.existsSync(real);
128
+ if (had) fs.copyFileSync(real, backup);
129
+ try {
130
+ fs.writeFileSync(real, '{ this is not json');
131
+ const r = rat.checkQualityRatchets();
132
+ assert.equal(r.hardFail, true, 'unparseable is no more verifiable than absent');
133
+ assert.match(r.violations[0], /not valid JSON/);
134
+ } finally {
135
+ if (had) { fs.copyFileSync(backup, real); fs.unlinkSync(backup); }
136
+ }
137
+ });
138
+
139
+ test('1003227 a baseline file with no "metrics" object hard-fails', async () => {
140
+ const fs = require('node:fs');
141
+ const real = rat.BASELINE_PATH;
142
+ const backup = `${real}.1003227-test-backup3`;
143
+ const had = fs.existsSync(real);
144
+ if (had) fs.copyFileSync(real, backup);
145
+ try {
146
+ fs.writeFileSync(real, JSON.stringify({ _comment: 'no metrics key' }));
147
+ const r = rat.checkQualityRatchets();
148
+ assert.equal(r.hardFail, true);
149
+ assert.match(r.violations[0], /no "metrics" object/);
150
+ } finally {
151
+ if (had) { fs.copyFileSync(backup, real); fs.unlinkSync(backup); }
152
+ }
153
+ });
154
+
155
+ test('1003227 a missing KEY for a NEW metric stays a warning (adding a metric must not brick CI)', () => {
156
+ // The two absences are different facts and must not collapse: no FILE = cannot
157
+ // verify = fail; no KEY = a genuinely new metric = warn.
158
+ const { violations, warnings } = rat.compareToBaselines(
159
+ { brand_new_metric: 7 },
160
+ { excess_lines_total: 100 },
161
+ );
162
+ assert.deepEqual(violations, [], 'a new metric with no floor yet must not fail the build');
163
+ assert.match(warnings.join(' '), /no baseline for "brand_new_metric"/);
164
+ });
165
+
166
+ test('1003227 a real regression is still caught when the baselines ARE readable', () => {
167
+ const { violations } = rat.compareToBaselines(
168
+ { excess_lines_total: 5100, require_cycle_count: 99 },
169
+ { excess_lines_total: 100, require_cycle_count: 5 },
170
+ );
171
+ assert.equal(violations.length, 2, 'both regressions reported');
172
+ assert.match(violations.join(' '), /never raise the baseline to pass/);
173
+ });
package/tests/helpers.mjs CHANGED
@@ -142,3 +142,16 @@ export function lifecycleDbSource(modulesDir = path.join(ROOT, 'modules')) {
142
142
  .sort();
143
143
  return files.map((f) => fs.readFileSync(path.join(dir, f), 'utf8')).join('\n');
144
144
  }
145
+
146
+ // shipCliSource — the ship CLI as one source string.
147
+ //
148
+ // The companion to lifecycleDbSource above, for the same reason: task 1003205
149
+ // carved the 4,613-line scripts/gds/ship.js into ship.js (a re-export facade +
150
+ // the CLI entry point) plus ship-*.js phase modules. Tests that assert "the ship
151
+ // CLI does X" by regexing its source mean the CLI as a whole, not one file.
152
+ export function shipCliSource(scriptsDir = path.join(ROOT, 'scripts', 'gds')) {
153
+ const files = fs.readdirSync(scriptsDir)
154
+ .filter((f) => f === 'ship.js' || (f.startsWith('ship-') && f.endsWith('.js')))
155
+ .sort();
156
+ return files.map((f) => fs.readFileSync(path.join(scriptsDir, f), 'utf8')).join('\n');
157
+ }
@@ -144,10 +144,16 @@ t('buildCorePinRefreshCommit (the commit step): git sequence, token-URL push, an
144
144
  root: '/srv/bongos/spike-harbor', fromVersion: '1.19.2', toVersion: '1.19.196',
145
145
  repo, ownerToken: 'ghs_xxx', credFile: '/tmp/cred',
146
146
  });
147
+ // task 1002757: the bot email is RESOLVED (env → instance apex → neutral
148
+ // .invalid), never the hub's own address hardcoded — a self-hosted provisioner
149
+ // must not attribute its commits to the hub. Assert against the resolver so
150
+ // this pins the behaviour, not one instance's literal.
147
151
  assert.deepEqual(withTok.config, [
148
152
  'git -C /srv/bongos/spike-harbor config user.name "Cloud Bongos"',
149
- 'git -C /srv/bongos/spike-harbor config user.email "bot@cloudbongos.com"',
153
+ `git -C /srv/bongos/spike-harbor config user.email "${P.provisionerBotEmail()}"`,
150
154
  ]);
155
+ assert.equal(P.provisionerBotEmail({ PROVISION_BOT_EMAIL: 'ops@acme.test' }), 'ops@acme.test', 'env override wins');
156
+ assert.doesNotMatch(P.provisionerBotEmail({}), /amazonprimea/i, 'never a founder-host address');
151
157
  assert.equal(withTok.add, 'git -C /srv/bongos/spike-harbor add -A');
152
158
  assert.match(withTok.commit, /@bongos\/core 1\.19\.2 -> 1\.19\.196 \(task 1003050\)/);
153
159
  assert.match(withTok.push, /store --file=\/tmp\/cred.*x-access-token@github\.com\/jaxri\/spike-harbor\.git HEAD:main/);
@@ -198,5 +198,48 @@ test('buildGradePayload confidence defaults to null when signals omit it', () =>
198
198
  assert.equal(body.subagent_output.confidence, null);
199
199
  });
200
200
 
201
+ // ---------- task 1003221: don't prescribe a command the caller can't run ----------
202
+ //
203
+ // The 'confirmed' answer hardcoded "run /merge-mode", which lands by pushing main
204
+ // via push-main.js (SEC #863 / ADR 0043) and needs an authenticated gh plus a main
205
+ // checkout. A server-mediated builder (pushVia()==='server' — every dev box) has
206
+ // neither, so the advice was unreachable by construction. It cost a real builder
207
+ // 163 minutes on 2026-08-21. strandNextStep already had the right wording for both
208
+ // modes; these pin that the two modes now diverge and that neither regresses.
209
+
210
+ test('1003221 a server-mediated builder is NOT told to run /merge-mode', () => {
211
+ const v = evaluateRegradeEligibility({
212
+ task: { id: 601, title: 't', status: 'confirmed' },
213
+ lastClaim: null,
214
+ builder: { id: 7, rank: 'archon' },
215
+ pushMode: 'server',
216
+ });
217
+ assert.equal(v.ok, false);
218
+ assert.match(v.reason, /already 'confirmed'/, 'still says why a re-grade is refused');
219
+ assert.doesNotMatch(v.reason, /run \/merge-mode/, 'must not prescribe the unreachable command');
220
+ assert.match(v.reason, /ship\.js 601/, 'names the command that DOES work here');
221
+ assert.match(v.reason, /NOT available here/, 'and says plainly why /merge-mode is out');
222
+ });
223
+
224
+ test('1003221 a laptop builder still gets /merge-mode (no regression)', () => {
225
+ const v = evaluateRegradeEligibility({
226
+ task: { id: 602, title: 't', status: 'confirmed' },
227
+ lastClaim: null,
228
+ builder: { id: 7, rank: 'archon' },
229
+ pushMode: 'local',
230
+ });
231
+ assert.match(v.reason, /\/merge-mode/, 'the laptop path keeps its working advice');
232
+ assert.match(v.reason, /ship\.js 602/, 'and still offers the resume alternative');
233
+ });
234
+
235
+ test('1003221 omitting pushMode keeps the historical wording (backward compatible)', () => {
236
+ const v = evaluateRegradeEligibility({
237
+ task: { id: 603, title: 't', status: 'confirmed' },
238
+ lastClaim: null,
239
+ builder: { id: 7, rank: 'archon' },
240
+ });
241
+ assert.match(v.reason, /\/merge-mode/, 'an old caller/test that passes no pushMode is unchanged');
242
+ });
243
+
201
244
  console.log(`\n--regrade eligibility (#678) tests: ${passed} passed, ${failed} failed`);
202
245
  process.exit(failed > 0 ? 1 : 0);
@@ -33,6 +33,7 @@ import { createRequire } from 'node:module';
33
33
  import fs from 'node:fs';
34
34
  import path from 'node:path';
35
35
  import url from 'node:url';
36
+ import { shipCliSource } from './helpers.mjs';
36
37
 
37
38
  const require = createRequire(import.meta.url);
38
39
  const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
@@ -178,8 +179,9 @@ await t('the CLI carries the declaration through its deliberate-empty path', ()
178
179
  // Without this, the gate would refuse exactly the --db-only / --allow-empty
179
180
  // flow CLAUDE.md calls a supported shape — turning a correctness fix into a
180
181
  // regression for the builders who rely on it.
181
- const src = read('scripts/gds/ship.js');
182
- assert.match(src, /no_artifact:\s*true/, 'ship.js must send the declaration on an empty land');
182
+ // task 1003205: the ship CLI is ship.js + ship-*.js now — read the family.
183
+ const src = shipCliSource();
184
+ assert.match(src, /no_artifact:\s*true/, 'the ship CLI must send the declaration on an empty land');
183
185
  assert.match(src, /landed\(\{ noArtifact: true/,
184
186
  'and the deliberate-empty land sites must mark themselves as such');
185
187
  });
@@ -115,5 +115,48 @@ test('the "non-fast-forward" wording triggers the same hint through the envelope
115
115
  });
116
116
 
117
117
  console.log('');
118
+ // ---------- task 1003221: a non-fast-forward has TWO causes ----------
119
+ //
120
+ // The hint used to give ONE diagnosis — "the server already moved <branch>" — and
121
+ // prescribe `reset onto origin/<branch>`. That is right when the server is ahead
122
+ // and DESTRUCTIVE when it is not: a builder who rebased or amended an
123
+ // already-published branch would be told to throw away the commits they just made.
124
+ // That was the real 2026-08-21 case. opts.localHistoryRewritten discriminates;
125
+ // null (git could not tell) must keep the original wording rather than guess.
126
+ const NFF = {
127
+ error: { code: 'publish_failed', message: 'publish_failed',
128
+ details: { step: 'push', reason: '! [rejected] main -> main (non-fast-forward)' } },
129
+ };
130
+
131
+ test('1003221 rewritten local history is told to MERGE, never reset', () => {
132
+ const out = ship.formatPublishFailure(NFF, 500, 'claude/x', { localHistoryRewritten: true });
133
+ assert.match(out, /APPEND-ONLY/);
134
+ assert.match(out, /git merge origin\/claude\/x/, 'names the merge that actually recovers');
135
+ assert.match(out, /Do NOT reset or re-rebase/, 'and forbids the lossy move explicitly');
136
+ assert.doesNotMatch(out, /reset onto/, 'must not carry the server-ahead remedy');
137
+ });
138
+
139
+ test('1003221 a server-ahead non-fast-forward keeps the reset remedy', () => {
140
+ const out = ship.formatPublishFailure(NFF, 500, 'claude/x', { localHistoryRewritten: false });
141
+ assert.match(out, /the server already moved claude\/x/);
142
+ assert.match(out, /reset onto origin\/claude\/x/);
143
+ assert.doesNotMatch(out, /APPEND-ONLY/);
144
+ });
145
+
146
+ test('1003221 an UNKNOWN cause (null) does not invent a diagnosis', () => {
147
+ const unknown = ship.formatPublishFailure(NFF, 500, 'claude/x', { localHistoryRewritten: null });
148
+ const legacy = ship.formatPublishFailure(NFF, 500, 'claude/x');
149
+ assert.equal(unknown, legacy, 'null must be byte-identical to the pre-fix wording');
150
+ assert.match(unknown, /reset onto/);
151
+ });
152
+
153
+ test('1003221 a non-NFF failure gains no hint from either flag', () => {
154
+ const other = { error: { code: 'publish_failed', details: { step: 'bundle', reason: 'bad object' } } };
155
+ for (const v of [true, false, null]) {
156
+ const out = ship.formatPublishFailure(other, 500, 'claude/x', { localHistoryRewritten: v });
157
+ assert.doesNotMatch(out, /APPEND-ONLY|reset onto/, `flag ${v} must not add a remedy to an unrelated failure`);
158
+ }
159
+ });
160
+
118
161
  console.log(`ship_publish_error: ${passed} passed, ${failed} failed`);
119
162
  process.exit(failed === 0 ? 0 : 1);