@bongos/core 1.19.931 → 1.19.933

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,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.931",
6
- "core_contract": "1.19.931",
7
- "source_commit": "b6d78155b15d9c6063a4ea7477c43f75e2bbab6c",
5
+ "core_version": "1.19.933",
6
+ "core_contract": "1.19.933",
7
+ "source_commit": "3ad7b40118fcad3d4ac54b7c18e2ccc36d3e7a6d",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-21T01:31:35.320Z",
9
+ "built_at": "2026-09-21T02:57:49.721Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 511,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2862,
20
- "tree_sha256": "ded0626099adb90597501b45fcca7742db0c45a765194e726a9befad8aa63c47",
20
+ "tree_sha256": "0c42a78bb3f237ab0facaf9fee094236c11c19543f7a3deb08d9616db322b27f",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2677,7 +2677,7 @@
2677
2677
  {
2678
2678
  "path": "docs/module-api-changelog.md",
2679
2679
  "mode": "0000644",
2680
- "sha256": "1ceef6efa3c35c5d63b9873f2cc0a981a5646727fdd62216616f48f4d2cfeae2"
2680
+ "sha256": "5c21da8442aac7c6bc744635d1e082b89e321f25743bcc0b7d6dbc659a7b7f31"
2681
2681
  },
2682
2682
  {
2683
2683
  "path": "docs/modules-contract.md",
@@ -7967,12 +7967,12 @@
7967
7967
  {
7968
7968
  "path": "package-lock.json",
7969
7969
  "mode": "0000644",
7970
- "sha256": "010ad7a7b802e8d4d32190e622f0adc1311aa59728a9e94663c9daa6871b99e1"
7970
+ "sha256": "23de484d5a08f0dc84ce22fb8d7671a3efbd0273ce878c1a63d6848fc1796273"
7971
7971
  },
7972
7972
  {
7973
7973
  "path": "package.json",
7974
7974
  "mode": "0000644",
7975
- "sha256": "937a9b3f81ddbcea577192fa28a4c90562cd5f17ecbe55115ee6b63dcae3f07f"
7975
+ "sha256": "0a74e65c4330c8d5b855b5bc751fea858b493130d5c205f49d213bfe674a46b7"
7976
7976
  },
7977
7977
  {
7978
7978
  "path": "public-docs/index.html",
@@ -8322,7 +8322,7 @@
8322
8322
  {
8323
8323
  "path": "scripts/gds/claude-materialize.js",
8324
8324
  "mode": "0000644",
8325
- "sha256": "37cbab033c9ba88ae557e8569fcabca9d361b4e26fc335ed5adacddf245ab38d"
8325
+ "sha256": "2209cc2ca1476efc0af464e6e58ae87c5a5dc8d5a70b2ad067867adbfe0aab06"
8326
8326
  },
8327
8327
  {
8328
8328
  "path": "scripts/gds/claude-md-template.js",
@@ -9522,7 +9522,7 @@
9522
9522
  {
9523
9523
  "path": "scripts/gds/upgrade.js",
9524
9524
  "mode": "0000644",
9525
- "sha256": "9bf33575839de86c1361f78dab69f0e4607db37bbb473a38928fa6c03447d31e"
9525
+ "sha256": "d736a08f303447e3889c478b0a22d6e9ae96e326f75bd57bead6199ffc23f1c5"
9526
9526
  },
9527
9527
  {
9528
9528
  "path": "scripts/gds/validate-design.js",
@@ -9782,7 +9782,7 @@
9782
9782
  {
9783
9783
  "path": "src/bongos/module-scope-map.js",
9784
9784
  "mode": "0000644",
9785
- "sha256": "40f2196830c746ed440ba91b938bece8ba48fdf225285c6a6d5dccd109d47d3d"
9785
+ "sha256": "b5ee44e89d9686d1fc297dfd9952d3614edfe7f4a4cd18c264d9b4d68adb2629"
9786
9786
  },
9787
9787
  {
9788
9788
  "path": "src/bongos/module-submissions.js",
@@ -9972,7 +9972,7 @@
9972
9972
  {
9973
9973
  "path": "src/module-api.js",
9974
9974
  "mode": "0000644",
9975
- "sha256": "6d65e90e4121fcaa45780d9aa68eb2abab94d20cd512cdf9f165ea6797fed953"
9975
+ "sha256": "3870b0f3400bed8195ac80fa450e8e1f10d5b4f559f46b131db7b633669fc95f"
9976
9976
  },
9977
9977
  {
9978
9978
  "path": "src/module-loader/catalog.js",
@@ -14167,17 +14167,17 @@
14167
14167
  {
14168
14168
  "path": "tests/update_subscription_engine.mjs",
14169
14169
  "mode": "0000644",
14170
- "sha256": "a2ba2dca70c6192313c7a64e6dcbb5d7f9dbf2dc457592e643f1d59154b2665f"
14170
+ "sha256": "a083174381f10d1cd19795b43580a3113c4f758e4b98da1f7c8e389429cb6a26"
14171
14171
  },
14172
14172
  {
14173
14173
  "path": "tests/upgrade.mjs",
14174
14174
  "mode": "0000644",
14175
- "sha256": "3fe93c21665f73be4e2c17198af35626b2a3a045eceadda8827195910ad64e4c"
14175
+ "sha256": "8bd1c116b5459b54a847208c01669159414fed17b3704d621e1dc5acfb00f255"
14176
14176
  },
14177
14177
  {
14178
14178
  "path": "tests/upgrade_persist_pin.mjs",
14179
14179
  "mode": "0000644",
14180
- "sha256": "2e1fd181c580070c3625035d39d038c137dbf04c6c8e74132977b877c0d73c76"
14180
+ "sha256": "47698d59e0f728fee112bc55989a2271293007e4fba2b3447a0d2e4a3a93401a"
14181
14181
  },
14182
14182
  {
14183
14183
  "path": "tests/verify_task_guard.mjs",
@@ -2336,5 +2336,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2336
2336
  landed since 1.19.929 with no explicit bump. run 35549362371. (task 1002620)
2337
2337
  1.19.931 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2338
2338
  landed since 1.19.930 with no explicit bump. run 35551146764. (task 1002620)
2339
+ 1.19.932 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2340
+ landed since 1.19.931 with no explicit bump. run 35555080510. (task 1002620)
2341
+ 1.19.933 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2342
+ landed since 1.19.932 with no explicit bump. run 35555853466. (task 1002620)
2339
2343
  ---------------------------------------------------------------------------
2340
2344
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.931",
3
+ "version": "1.19.933",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.931",
9
+ "version": "1.19.933",
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.931",
3
+ "version": "1.19.933",
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",
@@ -32,7 +32,7 @@ const path = require('node:path');
32
32
  const { spawnSync } = require('node:child_process');
33
33
  const { VERBS } = require('../../bin/bongos.js'); // side-effect-free require (guarded main)
34
34
  const ic = require('../../src/instance-config'); // neutral configDir/envPrefix fallbacks (never a founder literal)
35
- const { isCorePackageName } = require('../../src/module-loader/catalog'); // one definition of "this checkout IS the core"
35
+ const { isCorePackageName, CORE_PACKAGE_NAMES } = require('../../src/module-loader/catalog'); // one definition of "this checkout IS the core"
36
36
  const { renderInstanceClaudeMd } = require('./claude-md-template'); // task 2104 — the starter CLAUDE.md
37
37
 
38
38
  // script basename (e.g. "ship.js") → dedicated bongos verb, for verbs that map to a bare script with
@@ -630,11 +630,19 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
630
630
  summary.notes.push('no .instance-skills.json provenance in .claude/skills/ yet, so nothing was withdrawn — this run records it; a later run can withdraw the skills of a module that has since been disabled.');
631
631
  }
632
632
  // Record what landed, so the NEXT run can withdraw what this one placed.
633
- if (!dryRun && overwrite) {
633
+ //
634
+ // REPORTED on a dry run, written only on a real one — the same split the starter CLAUDE.md
635
+ // below already uses. writtenPaths is a "what this run rewrites" list, and a dry run's job is
636
+ // to preview exactly that: upgrade.js asks for one to decide which dirty paths its clean-tree
637
+ // pre-flight may ignore (task 1004122), so a generated file missing from the preview is a file
638
+ // the pre-flight halts on and the real run then rewrites anyway.
639
+ if (overwrite) {
634
640
  const names = Object.keys(landedNow).sort();
635
641
  const manifest = { _doc: 'GENERATED by scripts/gds/claude-materialize.js (task 1003632): the module-declared skills a materialize landed in this instance. It is the provenance record that lets a later run WITHDRAW a skill whose module has since been disabled — a name absent from here is never deleted, so a hand-written skill is safe. Commit it; do not hand-edit.', skills: Object.fromEntries(names.map((n) => [n, landedNow[n]])) };
636
- fs.mkdirSync(skillsDest, { recursive: true });
637
- fs.writeFileSync(path.join(skillsDest, INSTANCE_SKILLS_MANIFEST), JSON.stringify(manifest, null, 2) + '\n');
642
+ if (!dryRun) {
643
+ fs.mkdirSync(skillsDest, { recursive: true });
644
+ fs.writeFileSync(path.join(skillsDest, INSTANCE_SKILLS_MANIFEST), JSON.stringify(manifest, null, 2) + '\n');
645
+ }
638
646
  addWritten(path.join('.claude/skills', INSTANCE_SKILLS_MANIFEST));
639
647
  }
640
648
 
@@ -741,6 +749,41 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
741
749
  return summary;
742
750
  }
743
751
 
752
+ // materializeWillRewrite — the instance-relative paths a materialize WOULD rewrite here, changing
753
+ // nothing. `bongos upgrade` asks before it starts, to decide which dirty paths its clean-tree
754
+ // pre-flight may waive.
755
+ //
756
+ // THE WEDGE THIS EXISTS FOR (task 1004122). The upgrade's materialize step rewrites `.claude/`
757
+ // from the new core on EVERY bump, so after an upgrade that did not commit it — one from before
758
+ // task 1003957, or any run without --commit-pin — the instance carries ~40 modified
759
+ // `.claude/skills/*` files that nobody edited. upgrade.js's clean-tree pre-flight then read them
760
+ // as a human's uncommitted work and refused, so the instance took exactly one upgrade and stuck:
761
+ // `test4` (instance 11) failed its Deploy-page upgrade this way, and provision-core-upgrade.js
762
+ // passes --commit-pin but NOT --force, so the button stayed dead until someone committed by hand.
763
+ //
764
+ // Asking materializeClaude what it WOULD write is a real provenance answer, not a path
765
+ // allow-list: the set is derived from the core actually installed here, and every path in it
766
+ // is one the materialize step overwrites unconditionally a few seconds later. Whatever those files hold right
767
+ // now is discarded either way, so refusing on them protects nothing and stops every future
768
+ // update. A path materialize would NOT touch — an owner's own skill, a dirty migration, a
769
+ // half-finished edit — is absent from the set and still halts the bump, which is the whole
770
+ // safety property. Under --commit-pin the same paths come back from that step as persistPin's
771
+ // artifactPaths and are committed, so the tree ends clean and the next upgrade needs none of this.
772
+ //
773
+ // Probed against the core on disk NOW, not the target: the target is not installed yet, and the
774
+ // two ship the same `.claude/` surface. Fail-soft — a materialize that throws (no core
775
+ // installed, an unreadable tree) returns nothing and the pre-flight is as strict as it ever was.
776
+ function materializeWillRewrite({ instanceDir, coreRoot, materialize = materializeClaude }, err = console.error) {
777
+ try {
778
+ const root = coreRoot || path.join(instanceDir, 'node_modules', CORE_PACKAGE_NAMES[0]);
779
+ const sum = materialize({ coreRoot: root, instanceDir, dryRun: true, log: () => {} });
780
+ return (sum && Array.isArray(sum.writtenPaths)) ? sum.writtenPaths : [];
781
+ } catch (e) {
782
+ err(` ! could not preview the .claude/ materialization (${e && e.message ? e.message : e}) — pre-flight will treat every dirty path as a human's work.`);
783
+ return [];
784
+ }
785
+ }
786
+
744
787
  // ---- the dev checkout: module-owned skills materialised INTO the core (task 1003330, ADR 0224) ----
745
788
  // materializeClaude's self-copy guard is right for init/upgrade (instance == core is a no-op),
746
789
  // but Claude Code discovers only .claude/skills/ at the project root, so a builder working IN
@@ -850,4 +893,4 @@ if (require.main === module) {
850
893
  process.exit(s.refused.length ? 1 : 0);
851
894
  }
852
895
 
853
- 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 };
896
+ module.exports = { scriptToVerb, bongosify, transformSettings, readSettingsTransform, renderStarterSettings, STARTER_ALLOW, copyTreeTransformed, materializeClaude, materializeWillRewrite, 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 };
@@ -129,11 +129,13 @@ function restorePin(instanceDir, depString, fsImpl = fs) {
129
129
 
130
130
  // ---- steps (each injectable for tests) --------------------------------------
131
131
 
132
- function gitTreeClean(instanceDir, run = spawnSync) {
132
+ // `ignore` is waived (it comes from — and is argued for in — claude-materialize.js materializeWillRewrite, task 1004122); every OTHER dirty path still halts the bump.
133
+ function gitTreeClean(instanceDir, run = spawnSync, ignore = []) {
133
134
  const r = run('git', ['status', '--porcelain'], { cwd: instanceDir, encoding: 'utf8' });
134
135
  if (!r || r.error || r.status !== 0) return { ok: false, reason: 'git status failed (not a git repo?)' };
135
- const dirty = (r.stdout || '').trim();
136
- return { ok: dirty === '', dirty };
136
+ const skip = new Set(ignore), all = (r.stdout || '').split('\n').filter((l) => l.trim());
137
+ const kept = all.filter((l) => !skip.has(l.slice(3).trim()));
138
+ return { ok: !kept.length, dirty: kept.join('\n'), ignored: all.length - kept.length };
137
139
  }
138
140
 
139
141
  // ---- persisting the pin (task 1002712) --------------------------------------
@@ -1114,8 +1116,11 @@ async function runUpgrade(opts, deps = {}) {
1114
1116
 
1115
1117
  // 1. pre-flight: clean tree (so the pin change is reviewable + revertable) + module compatibility.
1116
1118
  if (!opts.force) {
1117
- const tree = gitTreeClean(instanceDir, run);
1119
+ // Step 5's own output is not dirt (task 1004122) — but only when step 5 is going to run.
1120
+ const preview = opts.skipMaterialize ? () => [] : (deps.willRewrite || require('./claude-materialize').materializeWillRewrite);
1121
+ const tree = gitTreeClean(instanceDir, run, preview({ instanceDir, materialize }, err));
1118
1122
  if (!tree.ok) return { ok: false, error: `working tree not clean — commit/stash first, or pass --force.\n${tree.dirty || tree.reason || ''}` };
1123
+ if (tree.ignored) log(` • pre-flight: ignored ${tree.ignored} dirty path(s) this upgrade rewrites from the core (.claude/ materialization)${opts.commitPin ? ' — they are committed with the pin' : ' — pass --commit-pin to commit them'}.`);
1119
1124
  }
1120
1125
  const mods = preflightModules({ instanceDir, targetVersion }, deps);
1121
1126
  log(` ✓ pre-flight: ${mods.note}`);
@@ -367,6 +367,17 @@ const MODULE_GLOBS = {
367
367
  '.claude/settings.json', '.claude/hooks/',
368
368
  '.husky/',
369
369
  '.npmrc', '.gitleaks.toml',
370
+ // .devcontainer/ is the same class as the entries above it — repo-level
371
+ // developer-environment config, not a feature's territory (task 1004029).
372
+ // It belonged to NO module and was not in SHARED_GLOBS, so isCovered() said
373
+ // false for it under EVERY possible scope_modules value: no goal could
374
+ // authorize a task that touched it, which is what stalled task 1003896
375
+ // (delete .devcontainer/). Given to devsecops rather than added to
376
+ // SHARED_GLOBS because it has a real owner — SHARED_GLOBS is for roots owned
377
+ // by nobody, and every addition there widens the wall for every goal at once.
378
+ // devsecops is PROTECTED, so this path is now Archon-only to scope; that is
379
+ // the correct floor for a file that defines what a builder's environment runs.
380
+ '.devcontainer/',
370
381
  'package.json', 'package-lock.json',
371
382
  'scripts/gds/fitness.js', 'scripts/gds/docs-entropy.js',
372
383
  'scripts/gds/redteam-patrol.js', 'scripts/gds/audit-gate.js',
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.931'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.933'; // 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');
@@ -356,12 +356,74 @@ test('every commitPin call site in the lane decides about push', () => {
356
356
  }
357
357
  });
358
358
 
359
- test('the direct-upgrade fallback withholds --commit-pin unless the mode is push', () => {
359
+ test('the direct-upgrade fallback asks pinFlagsFor rather than deciding inline', () => {
360
360
  const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
361
- // --commit-pin makes upgrade.js commit AND push, so the fallback has to gate it too — the
362
- // policy must not depend on which engine the instance's core happened to support.
363
- assert.match(src, /if \(canCommitPin && pinMode === 'push'\) upArgs\.push\('--commit-pin'\)/,
364
- 'the fallback path would push a co-tenant pin through upgrade.js instead of the lane');
361
+ // The fourth version of this assertion. It used to pin the inline `if` verbatim, which froze
362
+ // the bug of task 1004122 in place: the line was correct about NOT pushing a co-tenant pin and
363
+ // wrong about how, and a test that quotes an expression can only ever confirm it is still there.
364
+ assert.match(src, /upArgs\.push\(\.\.\.pinFlagsFor\(\{ pinMode, canCommitPin, canPinNoPush \}\)\)/,
365
+ 'the fallback decides its pin flags inline again, where no test can reach them');
366
+ });
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // 8b. pinFlagsFor — the fallback's pin policy, and the cross-file contract (task 1004122)
370
+ //
371
+ // go-live.js is the PREFERRED engine and the fallback must not apply a weaker policy just
372
+ // because an instance's core could not support it. It did: the fallback withheld --commit-pin
373
+ // on `local-commit`, so upgrade.js left the `.claude/` it had just rewritten uncommitted, the
374
+ // lane's own net can stage nothing but PIN_FILES, and the next sweep halted on those files
375
+ // forever. The instance took exactly one upgrade and then needed a human.
376
+ // ---------------------------------------------------------------------------
377
+
378
+ test('pinFlagsFor: local-commit commits the bump WITHOUT pushing — the fix for the one-upgrade wedge', () => {
379
+ assert.deepEqual(
380
+ S.pinFlagsFor({ pinMode: 'local-commit', canCommitPin: true, canPinNoPush: true }),
381
+ ['--commit-pin', '--pin-no-push'],
382
+ 'upgrade.js commits the pin AND the .claude/ it materialized, and pushes nothing');
383
+ });
384
+
385
+ test('pinFlagsFor: push keeps --commit-pin alone, leave-dirty still commits nothing', () => {
386
+ assert.deepEqual(S.pinFlagsFor({ pinMode: 'push', canCommitPin: true, canPinNoPush: true }), ['--commit-pin']);
387
+ // --pin-no-push WITHOUT --commit-pin: nothing is committed, and upgrade.js's pull-deploy alarm
388
+ // drops to a note, because on this target no reset is coming to revert the bump.
389
+ assert.deepEqual(S.pinFlagsFor({ pinMode: 'leave-dirty', canCommitPin: true, canPinNoPush: true }), ['--pin-no-push']);
390
+ });
391
+
392
+ test('pinFlagsFor: an old core that lacks --pin-no-push gets NOTHING, never the lone --commit-pin', () => {
393
+ // parseArgs silently ignores an unknown flag, so the pair would degrade to a bare --commit-pin
394
+ // and push the bump into the CUSTOMER's repo. Fail closed: keep today's behaviour instead.
395
+ assert.deepEqual(S.pinFlagsFor({ pinMode: 'local-commit', canCommitPin: true, canPinNoPush: false }), []);
396
+ assert.deepEqual(S.pinFlagsFor({ pinMode: 'leave-dirty', canCommitPin: true, canPinNoPush: false }), []);
397
+ // …and a core with no --commit-pin at all is never asked to commit.
398
+ for (const mode of ['push', 'local-commit', 'leave-dirty']) {
399
+ assert.ok(!S.pinFlagsFor({ pinMode: mode, canCommitPin: false, canPinNoPush: true }).includes('--commit-pin'), `mode ${mode}`);
400
+ }
401
+ });
402
+
403
+ test('pinFlagsFor mirrors go-live.js upgradeCommand() mode for mode', () => {
404
+ // The cross-file contract, pinned at the source in the same way GO_LIVE_REQUIRED is: go-live
405
+ // builds a command STRING on a box, so the two can only be compared through its source.
406
+ const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'go-live.js'), 'utf8');
407
+ const body = src.slice(src.indexOf('function upgradeCommand('));
408
+ const commitPinModes = /\.\.\.\(mode === 'leave-dirty' \? \[\] : \['--commit-pin'\]\)/.test(body);
409
+ const noPushModes = /\.\.\.\(mode === 'push' \? \[\] : \['--pin-no-push'\]\)/.test(body);
410
+ assert.ok(commitPinModes && noPushModes, 'go-live.js upgradeCommand() moved — re-derive pinFlagsFor from it');
411
+ for (const mode of ['push', 'local-commit', 'leave-dirty']) {
412
+ const goLive = [
413
+ ...(mode === 'leave-dirty' ? [] : ['--commit-pin']),
414
+ ...(mode === 'push' ? [] : ['--pin-no-push']),
415
+ ];
416
+ assert.deepEqual(
417
+ S.pinFlagsFor({ pinMode: mode, canCommitPin: true, canPinNoPush: true }), goLive,
418
+ `mode ${mode}: the fallback applies a different pin policy from the engine it stands in for`);
419
+ }
420
+ });
421
+
422
+ test('supportsPinNoPush probes the file, and is fail-closed on anything it cannot read', () => {
423
+ assert.equal(S.supportsPinNoPush('/x/upgrade.js', () => 'usage: --commit-pin --pin-no-push'), true);
424
+ assert.equal(S.supportsPinNoPush('/x/upgrade.js', () => 'usage: --commit-pin'), false, 'a core that predates the flag');
425
+ assert.equal(S.supportsPinNoPush(null, () => '--pin-no-push'), false);
426
+ assert.equal(S.supportsPinNoPush('/x/upgrade.js', () => { throw new Error('ENOENT'); }), false);
365
427
  });
366
428
 
367
429
  // ---------------------------------------------------------------------------
package/tests/upgrade.mjs CHANGED
@@ -171,6 +171,58 @@ t('guard: dirty tree refuses (unless --force)', async () => {
171
171
  assert.match(res.error, /not clean/);
172
172
  });
173
173
 
174
+ // task 1004122 — the pre-flight must not refuse on what step 5 is about to overwrite anyway.
175
+ // An instance upgraded before task 1003957 carries ~40 modified `.claude/skills/*` nobody
176
+ // edited, and provision-core-upgrade.js (the Deploy page) passes --commit-pin but NOT --force,
177
+ // so the button stayed dead forever. The waiver is scoped to what a dry-run materialize on THIS
178
+ // instance says it rewrites; anything else still halts.
179
+ t('guard: a dirty tree that is ONLY this upgrade\'s own .claude/ materialization proceeds', async () => {
180
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
181
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
182
+ const dir = scratchConsumer();
183
+ const dryRuns = [];
184
+ const res = await u.runUpgrade(
185
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, healthCheck: false, skipMigrate: true },
186
+ {
187
+ log: () => {}, err: () => {},
188
+ run: fakeRun([], { dirty: ' M .claude/skills/builder-end/SKILL.md\n M .claude/settings.json', installTo: '1.15.0', dir }),
189
+ materialize: (a) => { if (a.dryRun) dryRuns.push(a); return { skills: 2, writtenPaths: ['.claude/skills/builder-end/SKILL.md', '.claude/settings.json'] }; },
190
+ regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
191
+ databaseUrl: '', pgDatabase: '',
192
+ }
193
+ );
194
+ assert.equal(res.ok, true, 'the bump runs, and step 5 rewrites those same files');
195
+ assert.equal(dryRuns.length, 1, 'the pre-flight previewed the materialization exactly once');
196
+ assert.equal(dryRuns[0].instanceDir, dir);
197
+ assert.ok(dryRuns[0].coreRoot.endsWith(join('node_modules', '@bongos', 'core')), 'against the core the instance has NOW — the target is not installed yet');
198
+ });
199
+
200
+ t('guard: a dirty tree with anything ELSE in it still refuses', async () => {
201
+ const dir = scratchConsumer();
202
+ const res = await u.runUpgrade({ to: '1.15.0', instance: dir }, {
203
+ log: () => {}, err: () => {},
204
+ run: fakeRun([], { dirty: ' M .claude/settings.json\n M migrations/core_253_wip.sql' }),
205
+ materialize: () => ({ skills: 1, writtenPaths: ['.claude/settings.json'] }),
206
+ });
207
+ assert.equal(res.ok, false);
208
+ assert.match(res.error, /not clean/);
209
+ assert.match(res.error, /migrations\/core_253_wip\.sql/, 'and it names the path that is genuinely a human\'s');
210
+ assert.ok(!res.error.includes('.claude/settings.json'), 'without re-listing the half it waived');
211
+ });
212
+
213
+ t('guard: --skip-materialize waives nothing — step 5 rewrites nothing, so every dirty path is real', async () => {
214
+ const dir = scratchConsumer();
215
+ let asked = 0;
216
+ const res = await u.runUpgrade({ to: '1.15.0', instance: dir, skipMaterialize: true }, {
217
+ log: () => {}, err: () => {},
218
+ run: fakeRun([], { dirty: ' M .claude/settings.json' }),
219
+ materialize: () => { asked += 1; return { skills: 1, writtenPaths: ['.claude/settings.json'] }; },
220
+ });
221
+ assert.equal(res.ok, false);
222
+ assert.match(res.error, /not clean/);
223
+ assert.equal(asked, 0, 'and materialize was never even asked');
224
+ });
225
+
174
226
  t('guard: already on the target refuses (unless --force)', async () => {
175
227
  const dir = scratchConsumer({ installed: '1.15.0' });
176
228
  const res = await u.runUpgrade({ to: '1.15.0', instance: dir }, { log: () => {}, err: () => {}, run: fakeRun([], { dirty: '' }) });
@@ -365,11 +365,10 @@ test('real git: an owner edit outside the generated files is never swept into th
365
365
  // when anything but the pin is dirty, which is simultaneously the `failed=1` on a successful
366
366
  // upgrade and the next sweep's permanent halt.
367
367
 
368
- const { materializeClaude } = require('../scripts/gds/claude-materialize.js');
368
+ const { materializeClaude, materializeWillRewrite } = require('../scripts/gds/claude-materialize.js');
369
369
 
370
370
  // A minimal core package: just the .claude/ tree materialize copies out of it.
371
- function scratchCore(version) {
372
- const dir = mkdtempSync(join(tmpdir(), 'upgrade-core-'));
371
+ function coreAt(dir, version) {
373
372
  put(dir, 'package.json', JSON.stringify({ name: '@bongos/core', version }));
374
373
  put(dir, 'config/branding.json', JSON.stringify({ identity: {} }));
375
374
  put(dir, '.claude/skills/builder-end/SKILL.md', `# builder-end (core ${version})\n`);
@@ -378,6 +377,13 @@ function scratchCore(version) {
378
377
  put(dir, '.claude/hooks/stop.js', `// hook for ${version}\n`);
379
378
  return dir;
380
379
  }
380
+ function scratchCore(version) {
381
+ return coreAt(mkdtempSync(join(tmpdir(), 'upgrade-core-')), version);
382
+ }
383
+ // The same tree where an instance really carries it — what materializeWillRewrite probes.
384
+ function installCore(instanceDir, version) {
385
+ return coreAt(join(instanceDir, 'node_modules', '@bongos', 'core'), version);
386
+ }
381
387
 
382
388
  // A scaffolded co-tenant that already TRACKS the .claude/ a previous materialize landed.
383
389
  function scratchCoTenant(version = '1.19.737') {
@@ -390,6 +396,7 @@ function scratchCoTenant(version = '1.19.737') {
390
396
  put(dir, 'package-lock.json', `lock ${version}\n`);
391
397
  put(dir, 'config/branding.json', JSON.stringify({ identity: {} }));
392
398
  put(dir, 'CLAUDE.md', 'the instance owns this\n');
399
+ put(dir, '.gitignore', 'node_modules/\n');
393
400
  materializeClaude({ coreRoot: scratchCore(version), instanceDir: dir, log: quiet });
394
401
  git(dir, 'add', '-A');
395
402
  git(dir, 'commit', '-q', '-m', 'scaffold');
@@ -477,3 +484,90 @@ test('adopt mode records nothing, so `init --adopt` never commits over what it r
477
484
  assert.ok(!sum.writtenPaths.includes('.claude/settings.json'));
478
485
  assert.equal(readFileSync(join(dir, '.claude/skills/builder-end/SKILL.md'), 'utf8'), 'the adopting project already had this\n');
479
486
  });
487
+
488
+ // ── the fleet that was ALREADY wedged (task 1004122) ────────────────────────────
489
+ //
490
+ // Task 1003957 above stops an upgrade CREATING the dirt. It does nothing for the instances
491
+ // that were upgraded before it shipped: every one of them still carries ~40 modified
492
+ // `.claude/skills/*` files nobody edited, and the clean-tree pre-flight refuses on them. So
493
+ // `test4` (instance 11) failed its Deploy-page upgrade on 2026-09-20 — provision-core-upgrade.js
494
+ // passes --commit-pin but NOT --force, so the button stays dead until someone commits by hand.
495
+ //
496
+ // The fix is that the pre-flight stops counting a path THIS upgrade is about to overwrite as
497
+ // evidence of anything. The set is not an allow-list: it is what a dry-run materialize against
498
+ // the core actually installed here says it would write.
499
+
500
+ test('gitTreeClean: an ignored path stops counting, and is reported rather than hidden', () => {
501
+ const dir = scratchCoTenant();
502
+ writeFileSync(join(dir, '.claude/skills/builder-end/SKILL.md'), 'rewritten by an earlier upgrade\n');
503
+ assert.equal(gitTreeClean(dir).ok, false, 'by default it is dirt like any other');
504
+ const t = gitTreeClean(dir, spawnSync, ['.claude/skills/builder-end/SKILL.md']);
505
+ assert.equal(t.ok, true);
506
+ assert.equal(t.ignored, 1, 'and the caller can say how many it waived');
507
+ assert.equal(t.dirty, '');
508
+ });
509
+
510
+ test('gitTreeClean: ignoring the materialized paths never waives anything else', () => {
511
+ const dir = scratchCoTenant();
512
+ writeFileSync(join(dir, '.claude/skills/builder-end/SKILL.md'), 'rewritten by an earlier upgrade\n');
513
+ put(dir, 'config/branding.json', 'the owner was here\n');
514
+ const t = gitTreeClean(dir, spawnSync, ['.claude/skills/builder-end/SKILL.md']);
515
+ assert.equal(t.ok, false, 'the owner edit still halts the bump');
516
+ assert.match(t.dirty, /config\/branding\.json/);
517
+ assert.ok(!t.dirty.includes('builder-end'), 'and only the materialized half was waived');
518
+ assert.equal(t.ignored, 1);
519
+ });
520
+
521
+ test('materializeWillRewrite: the set comes from the core the instance actually has', () => {
522
+ const dir = scratchCoTenant();
523
+ installCore(dir, '1.19.785');
524
+ const paths = materializeWillRewrite({ instanceDir: dir, materialize: materializeClaude }, quiet);
525
+ assert.ok(paths.includes('.claude/skills/builder-end/SKILL.md'));
526
+ assert.ok(paths.includes('.claude/settings.json'));
527
+ assert.ok(paths.includes('.claude/hooks/stop.js'));
528
+ assert.ok(paths.includes('.claude/skills/.instance-skills.json'), 'the provenance manifest is rewritten every run too');
529
+ assert.ok(!paths.includes('CLAUDE.md'), 'a file materialize writes only when ABSENT is not rewritten — the instance owns it');
530
+ assert.equal(git(dir, 'status', '--porcelain').stdout, '', 'and previewing it changed nothing on disk');
531
+ });
532
+
533
+ test('materializeWillRewrite: no core installed → no skill is waived, and a throw waives nothing at all', () => {
534
+ const dir = scratchCoTenant();
535
+ // Nothing to copy out of: materialize reports only the provenance record it rewrites anyway.
536
+ const paths = materializeWillRewrite({ instanceDir: dir, materialize: materializeClaude }, quiet);
537
+ assert.ok(!paths.some((p) => p.startsWith('.claude/skills/builder-')), 'no skill file is waived on the strength of a core that is not there');
538
+
539
+ // And an outright failure is fail-SOFT in the strict direction — an empty set plus a note.
540
+ const notes = [];
541
+ const boom = () => { throw new Error('unreadable core tree'); };
542
+ assert.deepEqual(materializeWillRewrite({ instanceDir: dir, materialize: boom }, (m) => notes.push(m)), []);
543
+ assert.equal(notes.length, 1, 'it says so rather than failing silently');
544
+ assert.match(notes[0], /unreadable core tree/);
545
+ });
546
+
547
+ test('real git: an instance wedged by a pre-1003957 upgrade unwedges itself on the next bump', () => {
548
+ const dir = scratchCoTenant();
549
+ sweep(dir, '1.19.737', '1.19.785', { artifactPaths: [] }); // the upgrade that left the mess
550
+ installCore(dir, '1.19.785'); // …and the core it installed
551
+ assert.equal(gitTreeClean(dir).ok, false, 'today: the next `bongos upgrade` refuses here');
552
+
553
+ // What runUpgrade's pre-flight now does.
554
+ const rewritable = materializeWillRewrite({ instanceDir: dir, materialize: materializeClaude }, quiet);
555
+ assert.equal(gitTreeClean(dir, spawnSync, rewritable).ok, true, 'the bump is allowed to proceed');
556
+
557
+ // …and proceeding is what cleans it up: step 5 rewrites those same paths and persistPin commits them.
558
+ const { pin } = sweep(dir, '1.19.785', '1.19.786');
559
+ assert.equal(pin.committed, true);
560
+ assert.equal(git(dir, 'status', '--porcelain').stdout, '', 'one bump, no human hands, tree clean');
561
+ });
562
+
563
+ test('real git: a wedged instance with an owner edit beside the mess still refuses', () => {
564
+ const dir = scratchCoTenant();
565
+ sweep(dir, '1.19.737', '1.19.785', { artifactPaths: [] });
566
+ installCore(dir, '1.19.785');
567
+ put(dir, 'core_999_half_written.sql', '-- not finished\n');
568
+ const rewritable = materializeWillRewrite({ instanceDir: dir, materialize: materializeClaude }, quiet);
569
+ const t = gitTreeClean(dir, spawnSync, rewritable);
570
+ assert.equal(t.ok, false, 'a human really does need to look');
571
+ assert.match(t.dirty, /core_999_half_written\.sql/);
572
+ assert.ok(!t.dirty.includes('.claude/skills/builder-end'), 'the materialization half is still waived');
573
+ });