@bongos/core 1.19.866 → 1.19.868

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.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.866",
6
- "core_contract": "1.19.866",
7
- "source_commit": "313d85e8328b2cf414e81eb6c971c3cd84724fb3",
5
+ "core_version": "1.19.868",
6
+ "core_contract": "1.19.868",
7
+ "source_commit": "6e35e3093dab9de214c45a87c227247fdc311bab",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-18T14:15:20.838Z",
9
+ "built_at": "2026-09-18T18:45:12.625Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 500,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2284,
14
+ "functional_verbatim": 2286,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2810,
20
- "tree_sha256": "beb2b02aa761274891c011957f1c0edf40e0b339d537014d78ce7b8c6231f11d",
19
+ "file_count": 2812,
20
+ "tree_sha256": "f61ec14f3d7f288cca2d20b1c27a0d0f4685e6e3474f344058c2bb7894cc3bef",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2602,7 +2602,7 @@
2602
2602
  {
2603
2603
  "path": "docs/module-api-changelog.md",
2604
2604
  "mode": "0000644",
2605
- "sha256": "f6cebe493fa035d5b336e1947d9d491ef051f266388108c55f6402572db6dd6e"
2605
+ "sha256": "3a71551ddb488315c33aa0f3bd4850e96f91e79940e801eeaa9c5c9304cdc653"
2606
2606
  },
2607
2607
  {
2608
2608
  "path": "docs/modules-contract.md",
@@ -7927,12 +7927,12 @@
7927
7927
  {
7928
7928
  "path": "package-lock.json",
7929
7929
  "mode": "0000644",
7930
- "sha256": "5d3ee0b5b578882947d9ef96d04ba34877589d6f8f3cafa33ef73bdda178feb8"
7930
+ "sha256": "89633564a9bfe53d895b487d7d0d06e8f23c3c1fcbabca119e7be607be5986b5"
7931
7931
  },
7932
7932
  {
7933
7933
  "path": "package.json",
7934
7934
  "mode": "0000644",
7935
- "sha256": "e491cf6aef48a2644a7c639bae0d7e2ec4f258e7d00aaedbd08a884d4c1c92d5"
7935
+ "sha256": "266b5d6ca4a1c4342a451577885abc475091a30a1e9bceb3a1749e579e97121d"
7936
7936
  },
7937
7937
  {
7938
7938
  "path": "public-docs/index.html",
@@ -8242,7 +8242,7 @@
8242
8242
  {
8243
8243
  "path": "scripts/gds/claude-materialize.js",
8244
8244
  "mode": "0000644",
8245
- "sha256": "488a2a6c55c794625c47480def16c3b3fee81be4bd73d36a21f11a7feab741fc"
8245
+ "sha256": "30fa12a96df4e9eda7e81f5abe3886caa0fb7dd277e37c5f6388c9acc429950e"
8246
8246
  },
8247
8247
  {
8248
8248
  "path": "scripts/gds/claude-md-template.js",
@@ -9264,10 +9264,15 @@
9264
9264
  "mode": "0000644",
9265
9265
  "sha256": "03095c6a2c57a9b1c91688d26320dcb5749dc59d5eb468db105dfad27dcd58e9"
9266
9266
  },
9267
+ {
9268
+ "path": "scripts/gds/ship-merge-gate.js",
9269
+ "mode": "0000644",
9270
+ "sha256": "34c28500eb2c4324f44c5b3604049c305d7b7921b57557ee325ece88b6293252"
9271
+ },
9267
9272
  {
9268
9273
  "path": "scripts/gds/ship-merge.js",
9269
9274
  "mode": "0000644",
9270
- "sha256": "0a1a9be854950574c554ab4a38d92123eeb6b283f2e17a4a9c1ce07aa80beaee"
9275
+ "sha256": "b85dd18d8214f1e732d4bd584fbad037c5505a16703e867e18e42bafef0bed33"
9271
9276
  },
9272
9277
  {
9273
9278
  "path": "scripts/gds/ship-preflight-steps.js",
@@ -9877,7 +9882,7 @@
9877
9882
  {
9878
9883
  "path": "src/module-api.js",
9879
9884
  "mode": "0000644",
9880
- "sha256": "ee2e636d5a1daea33068efecea345e13095aec56718b589e08dda05ed1fc5791"
9885
+ "sha256": "a1045f7acc428f16a6ebdfbcdc8ecf0eb0829c16fde30829b3c8541150372c05"
9881
9886
  },
9882
9887
  {
9883
9888
  "path": "src/module-loader/catalog.js",
@@ -10612,7 +10617,7 @@
10612
10617
  {
10613
10618
  "path": "tests/claude_materialize.mjs",
10614
10619
  "mode": "0000644",
10615
- "sha256": "ac4c44006a151190a5ead99c7a32241cf95e21b3eca397ea7c0d4bf26b0b7de9"
10620
+ "sha256": "36c52641c98217d42c38853c5dd78d4d4eb51ee97005380318db5adb2aa71d28"
10616
10621
  },
10617
10622
  {
10618
10623
  "path": "tests/clawback_confirmed_backlog.mjs",
@@ -13444,6 +13449,11 @@
13444
13449
  "mode": "0000644",
13445
13450
  "sha256": "a3b7858c255f1c7ea692866dbadd7321f77bd88c8380c429b7cb5f6b1b65496b"
13446
13451
  },
13452
+ {
13453
+ "path": "tests/ship_merge_gate.mjs",
13454
+ "mode": "0000644",
13455
+ "sha256": "b498d2820f6e17948cb8a86637d16b248a3349ebd9933343dd4b335a239497b7"
13456
+ },
13447
13457
  {
13448
13458
  "path": "tests/ship_post_land_verify.mjs",
13449
13459
  "mode": "0000644",
@@ -2206,5 +2206,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2206
2206
  landed since 1.19.864 with no explicit bump. run 35295856053. (task 1002620)
2207
2207
  1.19.866 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2208
2208
  landed since 1.19.865 with no explicit bump. run 35355019469. (task 1002620)
2209
+ 1.19.867 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2210
+ landed since 1.19.866 with no explicit bump. run 35380895321. (task 1002620)
2211
+ 1.19.868 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2212
+ landed since 1.19.867 with no explicit bump. run 35381947323. (task 1002620)
2209
2213
  ---------------------------------------------------------------------------
2210
2214
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.866",
3
+ "version": "1.19.868",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.866",
9
+ "version": "1.19.868",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.866",
3
+ "version": "1.19.868",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -389,10 +389,37 @@ function renderStarterSettings() {
389
389
  }, null, 2)}\n`;
390
390
  }
391
391
 
392
- function transformSettings(srcPath) {
392
+ // readSettingsTransform — the transform, as a result rather than a string:
393
+ // { text, parsed, reason }. transformSettings below is the string face of it,
394
+ // kept because that is what the tests and the historical callers use.
395
+ //
396
+ // WHY A PARSE FAILURE IS COPIED THROUGH UNTOUCHED (task 1004023). The old
397
+ // fallback was `bongosify(raw)` — a best-effort regex rewrite over the whole
398
+ // file. The script-invocation regex ends in an optional quote so it can swallow the
399
+ // closing quote of a QUOTED PATH inside a command; run over raw JSON text it
400
+ // swallows the JSON string's own closing quote instead:
401
+ //
402
+ // bongosify('{"command": "node scripts/gds/strand-watch.js", }')
403
+ // -> {"command": "bongos exec scripts/gds/strand-watch.js, }
404
+ //
405
+ // So a file that was one trailing comma from valid was written to the instance
406
+ // MORE broken than it arrived, with its strings running into each other. A
407
+ // rewrite of text we could not parse is not worth a worse file: the raw bytes
408
+ // are copied through unchanged and the caller is handed a reason to report. The
409
+ // operator fixes one comma instead of unpicking mangled strings.
410
+ //
411
+ // The parse failure is NOT made fatal: the core's own settings.json is valid
412
+ // JSON (pinned by a test), so reaching this means the operator has edited
413
+ // theirs, and refusing the whole materialize over one file would be a worse
414
+ // trade than copying it and saying so.
415
+ function readSettingsTransform(srcPath) {
393
416
  const raw = fs.readFileSync(srcPath, 'utf8');
394
417
  let json;
395
- try { json = JSON.parse(raw); } catch { return bongosify(raw); } // malformed → best-effort text rewrite
418
+ try {
419
+ json = JSON.parse(raw);
420
+ } catch (err) {
421
+ return { text: raw, parsed: false, reason: (err && err.message) || 'not valid JSON' };
422
+ }
396
423
  const hooks = json && json.hooks;
397
424
  if (hooks && typeof hooks === 'object') {
398
425
  for (const event of Object.values(hooks)) {
@@ -416,7 +443,14 @@ function transformSettings(srcPath) {
416
443
  perms[key] = [...new Set(rules.flatMap((r) => (typeof r === 'string' ? spellings(r) : [r])))];
417
444
  }
418
445
  }
419
- return JSON.stringify(json, null, 2) + '\n';
446
+ return { text: JSON.stringify(json, null, 2) + '\n', parsed: true, reason: null };
447
+ }
448
+
449
+ // transformSettings — the string face of readSettingsTransform. A caller that
450
+ // only wants the bytes keeps working; one that needs to know the file could not
451
+ // be parsed calls readSettingsTransform and reads `parsed`.
452
+ function transformSettings(srcPath) {
453
+ return readSettingsTransform(srcPath).text;
420
454
  }
421
455
 
422
456
  // Materialize the .claude/ surface from `coreRoot` into `instanceDir`. Returns a summary.
@@ -426,7 +460,7 @@ function transformSettings(srcPath) {
426
460
  const HOOK_TEXT_EXT = new Set(['.js', '.mjs', '.cjs', '.json', '.sh', '.md', '.txt']); // hooks are .js — rebrand them too
427
461
 
428
462
  function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite = true, log = () => {} }) {
429
- const summary = { skipped: false, skills: 0, moduleSkills: 0, settings: false, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], withdrawnSkills: [], rebrandRules: 0, claudeMd: false, writtenPaths: [], notes: [] };
463
+ const summary = { skipped: false, skills: 0, moduleSkills: 0, settings: false, settingsUnparsed: false, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], withdrawnSkills: [], rebrandRules: 0, claudeMd: false, writtenPaths: [], notes: [] };
430
464
  // Every instance-relative path this run WROTE or REMOVED (task 1003957). `bongos upgrade`
431
465
  // hands these to persistPin as artifactPaths, exactly as task 1003926 already does for
432
466
  // regenerateApiArtifacts' output: committing only the pin left the materialized files dirty,
@@ -607,13 +641,22 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
607
641
  summary.skippedFiles.push('settings.json');
608
642
  log(' • .claude/settings.json already present — left untouched (adopt merges nothing here yet).');
609
643
  } else {
644
+ // task 1004023: an unparseable settings.json is copied through BYTE FOR
645
+ // BYTE — no bongosify, no rebrand. Both are regex passes written for the
646
+ // strings inside a parsed file, and over raw JSON text they damage it.
647
+ // The instance gets what the core had, plus a note naming the parse error.
648
+ const settingsOut = readSettingsTransform(settingsSrc);
610
649
  if (!dryRun) {
611
650
  fs.mkdirSync(destClaude, { recursive: true });
612
- fs.writeFileSync(settingsDest, rebrand(transformSettings(settingsSrc)));
651
+ fs.writeFileSync(settingsDest, settingsOut.parsed ? rebrand(settingsOut.text) : settingsOut.text);
613
652
  }
614
653
  addWritten('.claude/settings.json');
615
654
  summary.settings = true;
616
- log(` ${dryRun ? '[dry-run] would write' : '✓ wrote'} .claude/settings.json (hook commands + permission rules→bongos, identity rebranded)`);
655
+ if (!settingsOut.parsed) {
656
+ summary.settingsUnparsed = true;
657
+ summary.notes.push(`.claude/settings.json is not valid JSON (${settingsOut.reason}) — copied through UNCHANGED: no hook-command or permission-rule rewrite to bongos, and no identity rebrand. Fix the JSON at the core root and materialize again, or edit the instance copy by hand.`);
658
+ }
659
+ log(` ${dryRun ? '[dry-run] would write' : '✓ wrote'} .claude/settings.json ${settingsOut.parsed ? '(hook commands + permission rules→bongos, identity rebranded)' : '(UNCHANGED — not valid JSON, nothing rewritten)'}`);
617
660
  }
618
661
  } else {
619
662
  // Package-only install: the core root carries no settings.json to copy, so
@@ -802,4 +845,4 @@ if (require.main === module) {
802
845
  process.exit(s.refused.length ? 1 : 0);
803
846
  }
804
847
 
805
- module.exports = { scriptToVerb, bongosify, transformSettings, renderStarterSettings, STARTER_ALLOW, copyTreeTransformed, materializeClaude, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, readInstanceSkillsManifest, INSTANCE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
848
+ module.exports = { scriptToVerb, bongosify, transformSettings, readSettingsTransform, renderStarterSettings, STARTER_ALLOW, copyTreeTransformed, materializeClaude, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, readInstanceSkillsManifest, INSTANCE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
@@ -0,0 +1,187 @@
1
+ // scripts/gds/ship-merge-gate.js — the fitness gate on the MERGE RESULT, run
2
+ // between the local merge and the push that makes it main (task 1004032).
3
+ //
4
+ // WHY THIS EXISTS. Branch protection requires the `unit` check of a PULL
5
+ // REQUEST. The local-merge land path is not a pull request: ship-merge.js
6
+ // merges the task branch into main inside the main checkout and pushes the
7
+ // result straight to origin/main, so no required check is consulted at any
8
+ // point. Until this file, the only things standing between a builder's branch
9
+ // and a red main were the grader (which reads a diff, not the gates) and
10
+ // runSmoke (an HTTP liveness probe against the deployed instance). Neither runs
11
+ // fitness.js, and ship-check.js — which does — is a command the builder may
12
+ // choose to run, not a step of the ship.
13
+ //
14
+ // THE INCIDENT IT CLOSES (2026-09-17). Commit 89ac9620 (task 1000862) added two
15
+ // legacy-acronym literals to .github/workflows/integration.yml. Measured after
16
+ // the fact, on the commits themselves:
17
+ //
18
+ // 89ac9620 the BRANCH TIP legacy_gds_literals 1137, committed baseline 1135
19
+ // -> fitness.js exits non-zero. Already red.
20
+ // 272b664d the MERGE the same 1137 against the same 1135, since the
21
+ // merge changed nothing about those two literals.
22
+ // 41ce52d4 the main before fitness.js passes.
23
+ //
24
+ // So the guess in the original bug report — that a merge-ref-evaluated ratchet
25
+ // let a clean branch produce a red main — is WRONG, and the truth is worse: the
26
+ // branch was red on its own, and the ship pushed it to main regardless. This
27
+ // was never the subtle "green branch, red merge" case; nothing measured
28
+ // anything. main went red, and three tasks (1003456, 1004014, 1004021) sat at
29
+ // 'confirmed' with nothing able to land until the baseline was raised by hand
30
+ // to unblock the queue.
31
+ //
32
+ // WHAT IT DOES NOT DO. It does not punish a builder for damage that is already
33
+ // on main. A gate that refused every ship while main is red would have turned
34
+ // that same incident into a total deadlock instead of a bad afternoon — so when
35
+ // the merge result fails, the pre-merge base is measured too, and a failure
36
+ // already present there is reported and waved through. The rule is "do not make
37
+ // main worse", not "main must be perfect before you may ship".
38
+
39
+ 'use strict';
40
+
41
+ const fs = require('node:fs');
42
+ const os = require('node:os');
43
+ const path = require('node:path');
44
+ const { execFileSync } = require('node:child_process');
45
+
46
+ // mergeGateVerdict — PURE. The whole decision, over two measurements:
47
+ //
48
+ // mergeClean did fitness pass on the merge result (the tree about to be main)
49
+ // baseClean did it pass on the pre-merge base — true, false, or null for
50
+ // "could not be measured"
51
+ //
52
+ // The null case blocks on purpose. A failing merge result IS a red main
53
+ // whatever the base was doing; an unmeasurable base means the gate cannot show
54
+ // the failure was inherited, and the honest report is "this push makes main
55
+ // red and I cannot prove it was already red". The builder's own next step is
56
+ // the same either way — run fitness.js and read the failing lines.
57
+ function mergeGateVerdict({ mergeClean, baseClean }) {
58
+ if (mergeClean) return { block: false, reason: 'clean' };
59
+ if (baseClean === false) return { block: false, reason: 'pre-existing' };
60
+ if (baseClean === null || baseClean === undefined) return { block: true, reason: 'unmeasured-base' };
61
+ return { block: true, reason: 'regression' };
62
+ }
63
+
64
+ // gateMessage — PURE. The one line printed for each verdict, in the voice of
65
+ // the sibling bails in ship-merge.js.
66
+ function gateMessage(reason) {
67
+ switch (reason) {
68
+ case 'clean':
69
+ return ' fitness on the merge result: clean';
70
+ case 'pre-existing':
71
+ return ' fitness on the merge result: FAILS, but the pre-merge main fails too — inherited, not caused by this branch; proceeding';
72
+ case 'regression':
73
+ return ' auto-merge: fitness FAILS on the merge result and PASSES on the pre-merge main — this branch would turn main red; task stays at confirmed';
74
+ case 'unmeasured-base':
75
+ return ' auto-merge: fitness FAILS on the merge result and the pre-merge main could not be measured; task stays at confirmed';
76
+ default:
77
+ return ` auto-merge: fitness gate returned an unknown verdict (${reason})`;
78
+ }
79
+ }
80
+
81
+ // fitnessOutcome — one fitness.js run over the tree at `treeRoot`, as a
82
+ // TRI-STATE: 'clean', 'failing', or 'unmeasurable'.
83
+ //
84
+ // The three are kept apart on purpose. An earlier cut returned a boolean, which
85
+ // made a crashed run and a genuine finding the same answer — and since a
86
+ // FAILING base waves the ship through, a probe that merely broke would have
87
+ // certified any regression as inherited. A control that degrades to permissive
88
+ // under infra flakiness is not a control. 'unmeasurable' blocks instead.
89
+ //
90
+ // IT MUST BE THAT TREE'S OWN fitness.js, not this file's sibling copy.
91
+ // fitness.js resolves the repo it measures from its own __dirname and ignores
92
+ // cwd, so invoking the shipping worktree's copy with `cwd` pointed elsewhere
93
+ // measures the shipping worktree twice and reports "clean" for whatever it was
94
+ // asked about. That is not a hypothetical: the first cut of this gate did
95
+ // exactly that, and replaying the 2026-09-17 incident through it returned a
96
+ // confident pass on a tree that fails fitness. Running each tree's own gate is
97
+ // also the honest semantics — a base commit is measured by the rules that were
98
+ // committed with it, which is what CI would have done on that commit.
99
+ //
100
+ // Output is inherited so the failing lines land in the ship log where the
101
+ // builder reads them. A non-zero exit is a FINDING; anything else that goes
102
+ // wrong (no fitness.js in that tree, a spawn that never ran, a checker that
103
+ // crashed) is 'unmeasurable'.
104
+ function fitnessOutcome(treeRoot) {
105
+ const script = path.join(treeRoot, 'scripts', 'gds', 'fitness.js');
106
+ if (!fs.existsSync(script)) return 'unmeasurable';
107
+ try {
108
+ execFileSync(process.execPath, [script], { cwd: treeRoot, stdio: 'inherit', shell: false });
109
+ return 'clean';
110
+ } catch (err) {
111
+ // A real gate verdict is a clean non-zero exit. A spawn that never produced
112
+ // an exit code (ENOENT on the interpreter, a signal, a permissions refusal)
113
+ // is the infra case, and must not be read as "this tree fails".
114
+ return typeof err?.status === 'number' ? 'failing' : 'unmeasurable';
115
+ }
116
+ }
117
+
118
+ // runFitnessAt — the boolean face of fitnessOutcome, kept for the merge-result
119
+ // side where "not clean" is all the caller needs: a merge result that cannot be
120
+ // measured is one this gate will not certify either way.
121
+ function runFitnessAt(treeRoot) {
122
+ return fitnessOutcome(treeRoot) === 'clean';
123
+ }
124
+
125
+ // probeBaseClean — measure the PRE-MERGE base in a throwaway detached worktree,
126
+ // so the merge already sitting in the main checkout does not have to be unwound
127
+ // to ask the question. Returns true (base clean), false (base genuinely fails
128
+ // its own gate) or null (could not be measured at all).
129
+ //
130
+ // No `npm ci` and no node_modules junction: fitness.js reads the tree and
131
+ // shells out to git, and runs green in a bare worktree — verified on this repo.
132
+ // If that ever stops being true the probe reports 'unmeasurable' rather than
133
+ // inventing a verdict, and mergeGateVerdict blocks.
134
+ function probeBaseClean(repoRoot, baseSha) {
135
+ if (!baseSha) return null;
136
+ let dir = null;
137
+ try {
138
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-merge-gate-'));
139
+ execFileSync('git', ['worktree', 'add', '--detach', '--quiet', dir, baseSha], {
140
+ cwd: repoRoot, stdio: 'ignore', shell: false,
141
+ });
142
+ } catch (_) {
143
+ if (dir) removeProbe(repoRoot, dir);
144
+ return null;
145
+ }
146
+ try {
147
+ const outcome = fitnessOutcome(dir);
148
+ if (outcome === 'clean') return true;
149
+ if (outcome === 'failing') return false;
150
+ return null;
151
+ } finally {
152
+ removeProbe(repoRoot, dir);
153
+ }
154
+ }
155
+
156
+ // removeProbe — best-effort teardown. A husk left in the OS temp dir is
157
+ // untidy; a ship that dies because its cleanup threw is worse.
158
+ function removeProbe(repoRoot, dir) {
159
+ try {
160
+ execFileSync('git', ['worktree', 'remove', '--force', dir], { cwd: repoRoot, stdio: 'ignore', shell: false });
161
+ } catch (_) { /* fall through to the filesystem */ }
162
+ try {
163
+ fs.rmSync(dir, { recursive: true, force: true });
164
+ } catch (_) { /* nothing left to do */ }
165
+ }
166
+
167
+ // gateMergeResult — the I/O half: measure, decide, print. `baseSha` is the main
168
+ // tip as it stood BEFORE the branch was merged in (in ship-merge.js that is
169
+ // origin/main, since the ff-only step immediately above it guarantees the two
170
+ // are the same commit).
171
+ function gateMergeResult({ repoRoot, baseSha, log = console.log }) {
172
+ log(' fitness against post-merge state...');
173
+ const mergeClean = runFitnessAt(repoRoot);
174
+ const baseClean = mergeClean ? true : probeBaseClean(repoRoot, baseSha);
175
+ const verdict = mergeGateVerdict({ mergeClean, baseClean });
176
+ log(gateMessage(verdict.reason));
177
+ return verdict;
178
+ }
179
+
180
+ module.exports = {
181
+ fitnessOutcome,
182
+ gateMergeResult,
183
+ gateMessage,
184
+ mergeGateVerdict,
185
+ probeBaseClean,
186
+ runFitnessAt,
187
+ };
@@ -26,6 +26,7 @@ const {
26
26
  landed,
27
27
  } = require('./ship-land.js');
28
28
  const { runSmoke } = require('./ship-grade.js');
29
+ const { gateMergeResult } = require('./ship-merge-gate.js');
29
30
  const { dbOnlyIgnoredNotice } = require('./ship-honesty.js');
30
31
  const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateCopyInventory, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
31
32
  const { REPO_ROOT } = require('./ship-state.js');
@@ -247,6 +248,12 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
247
248
  return landBailed('the local main is ahead of origin/main', '/merge-mode');
248
249
  }
249
250
 
251
+ // The tree the merge result will be compared against (task 1004032). The
252
+ // ff-only step above has just proved local main IS origin/main, so this is
253
+ // main as the rest of the world currently sees it — read before the branch
254
+ // is merged in, because afterwards the commit is no longer reachable by name.
255
+ const baseSha = gitOk('git rev-parse HEAD', { cwd: REPO_ROOT });
256
+
250
257
  // #681: build the merge argv and run it WITHOUT a shell. valueSummary is
251
258
  // DB-sourced (untrusted) — passing it as a literal argv element means
252
259
  // `$(...)`/backticks in it can never be interpreted as a command.
@@ -261,6 +268,21 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
261
268
  return landBailed('post-merge smoke tests failed', '/merge-mode');
262
269
  }
263
270
 
271
+ // task 1004032: the merge result is about to BECOME main by a direct push,
272
+ // which no required status check gates. Run the same fitness gate CI would
273
+ // have run on a PR, and refuse the push when this branch is what turns main
274
+ // red. A failure already present on the pre-merge base is reported and
275
+ // waved through — see scripts/gds/ship-merge-gate.js for why.
276
+ const fitnessVerdict = gateMergeResult({ repoRoot: REPO_ROOT, baseSha });
277
+ if (fitnessVerdict.block) {
278
+ return landBailed(
279
+ fitnessVerdict.reason === 'regression'
280
+ ? 'fitness fails on the merge result and passes on the pre-merge main — this branch would turn main red'
281
+ : 'fitness fails on the merge result and the pre-merge main could not be measured',
282
+ 'node scripts/gds/fitness.js',
283
+ );
284
+ }
285
+
264
286
  console.log(' pushing merged main to origin...');
265
287
  // SEC #863: route through push-main.js, which carries the
266
288
  // OTB_ALLOW_MAIN_PUSH override so the pre-push permission gate lets THIS
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.866'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.868'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -832,5 +832,65 @@ t("an instance's OWN settings.json is never clobbered", () => {
832
832
  rmSync(inst, { recursive: true, force: true });
833
833
  });
834
834
 
835
+ // ---- an unparseable settings.json is copied through, not mangled (task 1004023) ----
836
+ // THE DEFECT. The fallback was `bongosify(raw)` — the command-string rewrite run over
837
+ // the whole FILE. The script-invocation regex ends in an optional quote so it can swallow the
838
+ // closing quote of a quoted PATH; over raw JSON text it swallows the JSON string's own
839
+ // closing quote, and a file one trailing comma from valid was written to the instance
840
+ // with its strings running into each other. The load-bearing assertion is therefore the
841
+ // BYTE-EQUALITY one: the fix is not "rewrite it better", it is "do not rewrite what you
842
+ // could not parse".
843
+ const BROKEN_SETTINGS = '{"command": "node scripts/gds/strand-watch.js", }'; // one trailing comma from valid
844
+
845
+ t('readSettingsTransform: malformed JSON is reported, not rewritten', () => {
846
+ const dir = mkdtempSync(join(tmpdir(), 'w3-bad-'));
847
+ const p = join(dir, 'settings.json');
848
+ writeFileSync(p, BROKEN_SETTINGS);
849
+ const out = m.readSettingsTransform(p);
850
+ assert.equal(out.parsed, false, 'a trailing comma must be reported as unparseable');
851
+ assert.equal(out.text, BROKEN_SETTINGS, 'the bytes must come through untouched');
852
+ assert.ok(out.reason && out.reason.length > 0, 'the caller needs something to print');
853
+ // The specific corruption from the bug report must not be reachable any more.
854
+ assert.ok(!out.text.includes('bongos exec'), 'the command rewrite ran over text it could not parse');
855
+ assert.equal((out.text.match(/"/g) || []).length, (BROKEN_SETTINGS.match(/"/g) || []).length,
856
+ 'a quote was eaten — this is the exact shape of the original defect');
857
+ rmSync(dir, { recursive: true, force: true });
858
+ });
859
+
860
+ t('readSettingsTransform: valid JSON still gets the full rewrite', () => {
861
+ const dir = mkdtempSync(join(tmpdir(), 'w3-good-'));
862
+ const p = join(dir, 'settings.json');
863
+ writeFileSync(p, JSON.stringify({ permissions: { allow: ['Bash(node scripts/gds/ship.js:*)'] } }, null, 2));
864
+ const out = m.readSettingsTransform(p);
865
+ assert.equal(out.parsed, true);
866
+ assert.ok(out.text.includes('bongos'), 'the parseable path must still rewrite to the bongos bin');
867
+ assert.equal(out.reason, null);
868
+ rmSync(dir, { recursive: true, force: true });
869
+ });
870
+
871
+ t('transformSettings keeps its string contract for existing callers', () => {
872
+ const dir = mkdtempSync(join(tmpdir(), 'w3-str-'));
873
+ const p = join(dir, 'settings.json');
874
+ writeFileSync(p, BROKEN_SETTINGS);
875
+ assert.equal(typeof m.transformSettings(p), 'string');
876
+ assert.equal(m.transformSettings(p), BROKEN_SETTINGS);
877
+ rmSync(dir, { recursive: true, force: true });
878
+ });
879
+
880
+ t('materializeClaude: a broken core settings.json reaches the instance unchanged, with a note', () => {
881
+ const core = mkdtempSync(join(tmpdir(), 'bad-core-'));
882
+ const inst = mkdtempSync(join(tmpdir(), 'bad-inst-'));
883
+ mkdirSync(join(core, '.claude'), { recursive: true });
884
+ writeFileSync(join(core, '.claude', 'settings.json'), BROKEN_SETTINGS);
885
+ const summary = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
886
+ assert.equal(readFileSync(join(inst, '.claude', 'settings.json'), 'utf8'), BROKEN_SETTINGS,
887
+ 'the instance must receive exactly what the core had, not a worse file');
888
+ assert.equal(summary.settingsUnparsed, true, 'the summary must record that nothing was rewritten');
889
+ assert.ok(summary.notes.some((n) => n.includes('not valid JSON')),
890
+ 'a silent copy-through is how an operator never learns their hooks were not rewritten');
891
+ rmSync(core, { recursive: true, force: true });
892
+ rmSync(inst, { recursive: true, force: true });
893
+ });
894
+
835
895
  console.log(`\nclaude_materialize: ${passed} passed, ${failed} failed`);
836
896
  process.exit(failed ? 1 : 0);
@@ -0,0 +1,172 @@
1
+ // tests/ship_merge_gate.mjs — the fitness gate on the merge result (task 1004032).
2
+ //
3
+ // WHY. The local-merge land path pushes straight to origin/main, where no
4
+ // required status check can reach it. On 2026-09-17 a branch that ALREADY
5
+ // failed fitness was merged and pushed by ship.js, main went red, and three
6
+ // confirmed tasks could not land until the baseline was raised by hand.
7
+ //
8
+ // The load-bearing assertions are the two that were wrong before this gate
9
+ // existed: a merge result that regresses a clean base must be REFUSED, and a
10
+ // failure inherited from an already-red base must NOT be — a gate that blocked
11
+ // on an inherited failure would have deadlocked that same incident instead of
12
+ // merely spoiling an afternoon.
13
+ //
14
+ // The decision is exercised as the pure function it is; the wiring is pinned
15
+ // separately by reading ship-merge.js, because a correct verdict that is never
16
+ // consulted, or consulted after the push, is worth nothing.
17
+ import assert from 'node:assert/strict';
18
+ import { test } from 'node:test';
19
+ import { createRequire } from 'node:module';
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const gate = require(path.join(ROOT, 'scripts', 'gds', 'ship-merge-gate.js'));
28
+
29
+ test('a clean merge result is waved through, and the base is never consulted', () => {
30
+ assert.deepEqual(gate.mergeGateVerdict({ mergeClean: true, baseClean: null }), { block: false, reason: 'clean' });
31
+ assert.deepEqual(gate.mergeGateVerdict({ mergeClean: true, baseClean: false }), { block: false, reason: 'clean' });
32
+ });
33
+
34
+ test('THE INCIDENT: a failing merge result over a clean base is REFUSED', () => {
35
+ const verdict = gate.mergeGateVerdict({ mergeClean: false, baseClean: true });
36
+ assert.equal(verdict.block, true, 'a branch that turns main red must not be pushed');
37
+ assert.equal(verdict.reason, 'regression');
38
+ });
39
+
40
+ test('an INHERITED failure is not the shipper’s fault — it must not block', () => {
41
+ const verdict = gate.mergeGateVerdict({ mergeClean: false, baseClean: false });
42
+ assert.equal(verdict.block, false, 'blocking here deadlocks every ship while main is red');
43
+ assert.equal(verdict.reason, 'pre-existing');
44
+ });
45
+
46
+ test('an unmeasurable base blocks — the gate may not guess innocence', () => {
47
+ for (const baseClean of [null, undefined]) {
48
+ const verdict = gate.mergeGateVerdict({ mergeClean: false, baseClean });
49
+ assert.equal(verdict.block, true);
50
+ assert.equal(verdict.reason, 'unmeasured-base');
51
+ }
52
+ });
53
+
54
+ test('every verdict reason carries a message that names its own case', () => {
55
+ const seen = new Set();
56
+ for (const reason of ['clean', 'pre-existing', 'regression', 'unmeasured-base']) {
57
+ const msg = gate.gateMessage(reason);
58
+ assert.match(msg, /fitness/, `${reason} must say what was measured`);
59
+ assert.equal(seen.has(msg), false, `${reason} reuses another verdict’s wording`);
60
+ seen.add(msg);
61
+ }
62
+ // A blocked ship has to say the task is parked, or the builder reads a bare
63
+ // failure and re-runs the ship instead of fixing the tree.
64
+ assert.match(gate.gateMessage('regression'), /confirmed/);
65
+ assert.match(gate.gateMessage('unmeasured-base'), /confirmed/);
66
+ });
67
+
68
+ test('probeBaseClean cannot answer without a base commit', () => {
69
+ // The null-sha case must not reach `git worktree add` at all: an empty ref
70
+ // there resolves to HEAD, which after the merge is the merge itself — the
71
+ // gate would then compare the merge result against ITSELF and always pass.
72
+ assert.equal(gate.probeBaseClean(ROOT, null), null);
73
+ assert.equal(gate.probeBaseClean(ROOT, ''), null);
74
+ });
75
+
76
+ test('WIRING: ship-merge.js runs the gate BEFORE the push that makes it main', () => {
77
+ const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-merge.js'), 'utf8');
78
+ const gateAt = src.indexOf('gateMergeResult({');
79
+ const pushAt = src.indexOf('push-main.js');
80
+ assert.notEqual(gateAt, -1, 'ship-merge.js must call the gate');
81
+ assert.notEqual(pushAt, -1, 'ship-merge.js must still push through push-main.js');
82
+ assert.ok(gateAt < pushAt, 'the gate runs after the push — it would measure a main that is already red');
83
+ // …and it must actually act on the verdict. A call whose result is dropped
84
+ // reads as a gate in a diff and is a no-op at runtime.
85
+ assert.match(src, /fitnessVerdict\.block/, 'the verdict must be consulted');
86
+ assert.match(src.slice(gateAt, pushAt), /return landBailed\(/, 'a blocked merge must bail before the push');
87
+ });
88
+
89
+ test('the gate measures the PRE-MERGE base, captured before the branch lands', () => {
90
+ const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-merge.js'), 'utf8');
91
+ const baseAt = src.indexOf('const baseSha =');
92
+ const mergeAt = src.indexOf('buildMergeArgv(branch, valueSummary)');
93
+ assert.notEqual(baseAt, -1, 'the base sha must be captured');
94
+ assert.ok(baseAt < mergeAt, 'the base sha is read after the merge — it would name the merge commit itself');
95
+ });
96
+
97
+ // The bug that the first cut of this gate actually shipped with, and that only
98
+ // a replay of the real incident caught: fitness.js resolves the repo it
99
+ // measures from its OWN __dirname and ignores cwd, so running this worktree's
100
+ // copy against another tree measures this worktree and calls the other one
101
+ // clean. These fixtures are exit codes, not real gates — if runFitnessAt ever
102
+ // goes back to invoking a fixed sibling script, the failing fixture returns
103
+ // true and this reds.
104
+ function fakeTree(exitCode) {
105
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-gate-fixture-'));
106
+ fs.mkdirSync(path.join(dir, 'scripts', 'gds'), { recursive: true });
107
+ const body = exitCode === 0 ? 'process.exit(0);\n' : `process.exit(${exitCode});\n`;
108
+ fs.writeFileSync(path.join(dir, 'scripts', 'gds', 'fitness.js'), body);
109
+ return dir;
110
+ }
111
+
112
+ test('runFitnessAt runs the MEASURED TREE\u2019s own fitness.js, not this one\u2019s', () => {
113
+ const green = fakeTree(0);
114
+ const red = fakeTree(1);
115
+ try {
116
+ assert.equal(gate.runFitnessAt(green), true, 'a passing tree must certify');
117
+ assert.equal(gate.runFitnessAt(red), false, 'a failing tree reported clean means the wrong tree was measured');
118
+ } finally {
119
+ fs.rmSync(green, { recursive: true, force: true });
120
+ fs.rmSync(red, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test('a tree with no fitness.js cannot be certified', () => {
125
+ const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-gate-bare-'));
126
+ try {
127
+ assert.equal(gate.runFitnessAt(bare), false, 'no gate present must read as "cannot certify", never as clean');
128
+ } finally {
129
+ fs.rmSync(bare, { recursive: true, force: true });
130
+ }
131
+ });
132
+
133
+ // The hacker finding on the first cut: a boolean outcome made "fitness crashed"
134
+ // and "fitness found something" the same answer, and since a FAILING base waves
135
+ // the ship through, a merely-broken probe would have certified a real
136
+ // regression as inherited. A control that degrades to permissive under infra
137
+ // flakiness is not a control.
138
+ test('a crashed run is UNMEASURABLE, not a finding', () => {
139
+ const crashed = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-gate-crash-'));
140
+ fs.mkdirSync(path.join(crashed, 'scripts', 'gds'), { recursive: true });
141
+ // A tree whose gate cannot even be parsed: node exits non-zero, so this one
142
+ // still reads as a finding — that is deliberate, it IS a broken committed
143
+ // gate. The infra case is the one below it: no gate in the tree at all.
144
+ try {
145
+ assert.equal(gate.fitnessOutcome(path.join(crashed, 'nope')), 'unmeasurable');
146
+ assert.equal(gate.fitnessOutcome(crashed), 'unmeasurable', 'a tree with no fitness.js cannot be judged');
147
+ } finally {
148
+ fs.rmSync(crashed, { recursive: true, force: true });
149
+ }
150
+ });
151
+
152
+ test('the tri-state keeps clean and failing apart', () => {
153
+ const green = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-gate-green-'));
154
+ const red = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-gate-red-'));
155
+ for (const [dir, code] of [[green, 0], [red, 1]]) {
156
+ fs.mkdirSync(path.join(dir, 'scripts', 'gds'), { recursive: true });
157
+ fs.writeFileSync(path.join(dir, 'scripts', 'gds', 'fitness.js'), `process.exit(${code});\n`);
158
+ }
159
+ try {
160
+ assert.equal(gate.fitnessOutcome(green), 'clean');
161
+ assert.equal(gate.fitnessOutcome(red), 'failing', 'a non-zero exit is a real verdict, not infra noise');
162
+ } finally {
163
+ fs.rmSync(green, { recursive: true, force: true });
164
+ fs.rmSync(red, { recursive: true, force: true });
165
+ }
166
+ });
167
+
168
+ test('an unmeasurable base blocks rather than waving the ship through', () => {
169
+ // The end-to-end shape of the finding: probeBaseClean returns null for a base
170
+ // it could not measure, and null blocks.
171
+ assert.equal(gate.mergeGateVerdict({ mergeClean: false, baseClean: null }).block, true);
172
+ });