@bongos/core 1.19.679 → 1.19.681

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.
@@ -16,6 +16,13 @@
16
16
  // mergeMainIntoBranch self-heal so the heal runs even where the driver isn't
17
17
  // installed per-clone.
18
18
  //
19
+ // THE DRIVER ONLY FIRES ON A CONFLICT. A merge that lands CLEAN still leaves the
20
+ // generated docs describing the pre-merge tree — new symbols, a new session log and a
21
+ // new skill all arrive without conflicting. Three things close that: ship.js (at ship
22
+ // time), the server's conflict-resolve.js step 7b (for a PR it resolves), and
23
+ // `.husky/post-merge` (task 1001431) for the bare local `git merge` / `git pull` that
24
+ // neither of those covers.
25
+ //
19
26
  // SAFETY: it regenerates the GENERATED content and 3-way-merges the hand-written
20
27
  // parts of nested docs; it succeeds ONLY if no conflict markers remain. A genuine
21
28
  // hand-written conflict falls through to a normal git conflict — it never invents a
@@ -61,7 +68,13 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..');
61
68
  // branches that both touch hall/status/landing copy regenerate them differently
62
69
  // and conflict on ordinary text; they join the whole-file group for the same
63
70
  // reason session-log-index.md did.
71
+ // task 1001431: the ROOT CLAUDE.md joined too. It is mostly hand-written, but §13 carries
72
+ // the bounded BEGIN/END GENERATED SESSION-LOG SNIPPET block (ADR 0062 §8) — so two sessions
73
+ // that each add a session-log file regenerate that block differently and ALWAYS conflict
74
+ // there, and nothing healed it: the driver did not know the file, and `merge=union` would be
75
+ // wrong for prose. Same shape as docs/file-map.md (3-way-merge the prose, re-render the block).
64
76
  const REGENERABLE = new Set([
77
+ 'CLAUDE.md',
65
78
  'docs/repo-map.md',
66
79
  'docs/file-map.md',
67
80
  'docs/session-log-index.md',
@@ -124,6 +137,22 @@ function regenerateResolved(relpath, { base = '', ours = '', theirs = '' } = {})
124
137
  return { content: CI.renderReport(CI.buildRegistry()), ok: true };
125
138
  }
126
139
 
140
+ // The ROOT CLAUDE.md (task 1001431): hand-written prose carrying ONE generated block, the
141
+ // §13 session-log snippet. 3-way-merge the prose, then re-render the snippet from the
142
+ // session-log files on disk. Must be handled BEFORE the nested-CLAUDE.md fallthrough below,
143
+ // which derives a directory by stripping `/CLAUDE.md` — for the root path that strips
144
+ // nothing and would ask gen-repo-map for a symbol block for a directory named "CLAUDE.md".
145
+ // It also needs none of the code-symbol model, so it sits with the other pre-buildModel rules.
146
+ // Same provisionality caveat as the whole-file rules above: collectEntries() scans the
147
+ // working tree, which during a merge is still ours, so the block this writes describes our
148
+ // side. That is what makes the merge COMPLETE; the post-merge regeneration (conflict-resolve
149
+ // step 7b / ship-preflight-steps' resolveInWorktree) is what makes it CORRECT.
150
+ if (relpath === 'CLAUDE.md') {
151
+ const merged = threeWayMerge(base, ours, theirs);
152
+ const out = SI.injectSnippet(merged, SI.renderSnippet(SI.collectEntries()));
153
+ return { content: out, ok: !hasMarkers(out) };
154
+ }
155
+
127
156
  const model = G.buildModel();
128
157
 
129
158
  // docs/repo-map.md is fully generated — ignore all three sides, rebuild it.
@@ -521,7 +521,20 @@ function healDocAssets({ coreRoot }, run = spawnSync, fsImpl = fs) {
521
521
  return { ok: failed.length === 0, ran, failed, missing };
522
522
  }
523
523
 
524
- function restartService({ service, restartCmd }, run = spawnSync) {
524
+ // Restart the instance service so the newly-installed core becomes the one being SERVED.
525
+ // Escalation is the whole difficulty here (task 1002884). A provisioned instance runs under a
526
+ // `User=` systemd unit, and the unattended sweep that restarts it has no TTY — so a bare
527
+ // `systemctl restart` from the owning (non-root) user returns "Interactive authentication
528
+ // required" and exits 1. We retry once through `sudo -n`, the same idiom dev.js and dev-lib.js
529
+ // already use for privileged systemctl; `-n` fails fast rather than blocking on a password
530
+ // prompt, so the retry costs nothing when it isn't permitted. An operator who prefers polkit
531
+ // over sudo can install the rule in docs/recipes/instance-service-restart.md instead — either
532
+ // path makes this return ok.
533
+ // `--restart-cmd` is NOT escalated: an explicit command is the operator's own contract.
534
+ // Trust boundary: `service` comes from `--service` or the subscription roster — operator-supplied
535
+ // config, never request input — and the escalation reaches only as far as the host's own sudoers
536
+ // or polkit scoping allows. Do not wire a caller that derives `service` from anything untrusted.
537
+ function restartService({ service, restartCmd }, run = spawnSync, deps = {}) {
525
538
  if (restartCmd) {
526
539
  const parts = restartCmd.split(/\s+/);
527
540
  const r = run(parts[0], parts.slice(1), { stdio: 'inherit' });
@@ -529,7 +542,14 @@ function restartService({ service, restartCmd }, run = spawnSync) {
529
542
  }
530
543
  if (service) {
531
544
  const r = run('systemctl', ['restart', service], { stdio: 'inherit' });
532
- return { ok: !!r && !r.error && r.status === 0, code: r && r.status };
545
+ if (r && !r.error && r.status === 0) return { ok: true, code: 0 };
546
+ const isRoot = deps.isRoot !== undefined
547
+ ? !!deps.isRoot
548
+ : (typeof process.getuid === 'function' && process.getuid() === 0);
549
+ if (isRoot) return { ok: false, code: r && r.status };
550
+ const s = run('sudo', ['-n', 'systemctl', 'restart', service], { stdio: 'inherit' });
551
+ const ok = !!s && !s.error && s.status === 0;
552
+ return { ok, code: ok ? 0 : (s && s.status), triedSudo: true, directCode: r && r.status };
533
553
  }
534
554
  return { ok: null, skipped: true };
535
555
  }
@@ -622,6 +642,68 @@ async function pollHealth(healthUrl, deps = {}) {
622
642
  }
623
643
  }
624
644
 
645
+ // ---- served-version verification (task 1002884) -----------------------------
646
+ // The gap this closes: after a restart, `readInstalledCoreVersion` reads node_modules on DISK
647
+ // and `pollHealth` proves only that *something* answers the port. Neither can tell "the new core
648
+ // is up" from "the old core never went down" — so when a restart silently failed on
649
+ // cloudbongos.com (2026-08-11), the health poll hit the STILL-RUNNING old process, got 200, and
650
+ // the bump reported "✓ upgrade complete" while live kept serving the previous core.
651
+ // `/version` reports the LIVE process's coreVersion (serve-internal.js, since 1.17.2), which is
652
+ // the one signal that distinguishes the two.
653
+
654
+ // Default the version endpoint to /version on the same origin as the health URL. Deriving rather
655
+ // than demanding a second flag is deliberate: every existing call site passes only --health-url,
656
+ // and a check that has to be opted into is off exactly where it is needed. Returns null when the
657
+ // health URL isn't parseable as one.
658
+ function deriveVersionUrl(healthUrl) {
659
+ try {
660
+ const u = new URL(healthUrl);
661
+ u.pathname = '/version';
662
+ u.search = '';
663
+ u.hash = '';
664
+ return u.toString();
665
+ } catch { return null; }
666
+ }
667
+
668
+ // Poll the version endpoint until it yields a parseable coreVersion or the budget elapses. Runs
669
+ // AFTER the health poll, so the service is already answering and the budget is short — this is
670
+ // re-reading a live endpoint, not waiting for a boot. A body without a usable coreVersion is
671
+ // "unreadable", NOT a mismatch: the caller must be able to tell "it says the wrong version"
672
+ // (a real failure) from "it wouldn't tell me" (unconfirmed), because only the first is proof.
673
+ async function pollServedVersion(versionUrl, deps = {}) {
674
+ const fetchImpl = deps.fetch || fetch;
675
+ const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
676
+ const now = deps.now || Date.now;
677
+ // Default to the health budget when a caller (or a test) has already narrowed it, else 15s.
678
+ const budgetMs = deps.versionBudgetMs != null
679
+ ? deps.versionBudgetMs
680
+ : (deps.healthBudgetMs != null ? deps.healthBudgetMs : 15_000);
681
+ // A hard attempt cap alongside the clock: `now` is injectable, and a frozen or non-advancing
682
+ // clock would otherwise spin this loop forever instead of giving up.
683
+ const maxAttempts = deps.versionMaxAttempts != null ? deps.versionMaxAttempts : 12;
684
+ const deadline = now() + budgetMs;
685
+ let attempt = 0;
686
+ let last = { ok: false, error: 'not attempted' };
687
+ for (;;) {
688
+ attempt++;
689
+ try {
690
+ const res = await fetchImpl(versionUrl, { signal: AbortSignal.timeout(5_000) });
691
+ if (!res || !res.ok) last = { ok: false, error: `status ${res && res.status}` };
692
+ else if (typeof res.json !== 'function') last = { ok: false, error: 'response carried no JSON body' };
693
+ else {
694
+ const body = await res.json();
695
+ const version = body && typeof body.coreVersion === 'string' ? body.coreVersion : null;
696
+ if (version) return { ok: true, version, attempts: attempt };
697
+ last = { ok: false, error: 'no coreVersion in the response body' };
698
+ }
699
+ } catch (e) {
700
+ last = { ok: false, error: e.message };
701
+ }
702
+ if (now() >= deadline || attempt >= maxAttempts) return { ...last, attempts: attempt, timedOut: true };
703
+ await sleep(Math.min(2_000, 500 * attempt));
704
+ }
705
+ }
706
+
625
707
  // ---- integrity-pin verification (task 1002216 / audit H8, finding F1) --------
626
708
  // The packager (package-core.js) computes tree_sha256 = canonicalTreeHash over the
627
709
  // redacted core files (+ the two synthesized files) and records it, the per-file
@@ -738,7 +820,9 @@ async function rollback({ instanceDir, snapshot, fromVersion, toVersion, opts },
738
820
  else err(` ! reinstall of previous core failed (${inst.reason || 'exit ' + inst.code}) — the instance may need manual repair`);
739
821
 
740
822
  // 3. restart back onto the previous core.
741
- const rs = restartService({ service: opts.service, restartCmd: opts.restartCmd }, run);
823
+ // `deps` is passed here for the same reason the forward path passes it: this restart runs
824
+ // AFTER a failure, so its sudo-escalation is the one that matters most and must be testable.
825
+ const rs = restartService({ service: opts.service, restartCmd: opts.restartCmd }, run, deps);
742
826
  if (rs.skipped) log(' • restart: no --service/--restart-cmd — restart the instance service manually to load the reverted core.');
743
827
  else if (!rs.ok) err(` ! restart after rollback failed (exit ${rs.code}) — restart the service manually.`);
744
828
  else log(` ✓ restarted ${opts.service || 'service'} on the previous core`);
@@ -961,10 +1045,24 @@ async function runUpgrade(opts, deps = {}) {
961
1045
  } else log(' • doc regen skipped (--skip-regen-docs)');
962
1046
 
963
1047
  // 6. restart.
964
- const rs = restartService({ service: opts.service, restartCmd: opts.restartCmd }, run);
1048
+ const rs = restartService({ service: opts.service, restartCmd: opts.restartCmd }, run, deps);
965
1049
  if (rs.skipped) log(` • restart: no --service/--restart-cmd — restart the instance service to load core ${targetVersion}.`);
966
- else if (!rs.ok) err(` ! restart command failed (exit ${rs.code}) — restart the service manually.`);
967
- else log(` ✓ restarted ${opts.service || 'service'}`);
1050
+ else if (!rs.ok) {
1051
+ // task 1002884: a failed restart is an upgrade FAILURE, not a warning. It used to fall
1052
+ // through to the verification below — but the old process is still up, so the health poll
1053
+ // passed against IT and the bump wrote a success row for a core that was never served.
1054
+ // Failing here also keeps disk and process consistent: rollback puts the previous core back
1055
+ // on disk, matching the one that is (still) running.
1056
+ err(` ✖ restart failed (exit ${rs.code})${rs.triedSudo ? ` — \`systemctl restart\` exited ${rs.directCode} and \`sudo -n systemctl restart\` could not escalate` : ''}`);
1057
+ err(' The previous core is still the one being served. Grant the restart a non-interactive path');
1058
+ err(' (sudoers NOPASSWD or a polkit rule — docs/recipes/instance-service-restart.md), then re-run.');
1059
+ const error = `restart failed (exit ${rs.code}) — the instance is still running the previous core`;
1060
+ if (rollbackOnFailure && snapshot) {
1061
+ const rb = await doRollback('restart failed');
1062
+ return { ok: false, error, fromVersion, toVersion: targetVersion, restartOk: false, rolledBack: true, rollback: rb };
1063
+ }
1064
+ return { ok: false, error, fromVersion, toVersion: targetVersion, restartOk: false };
1065
+ } else log(` ✓ restarted ${opts.service || 'service'}${rs.triedSudo ? ' (via sudo -n)' : ''}`);
968
1066
 
969
1067
  // 7. verify: installed version == target, optional /healthz, ledger row.
970
1068
  const installed = readInstalledCoreVersion(instanceDir, fsImpl);
@@ -985,12 +1083,48 @@ async function runUpgrade(opts, deps = {}) {
985
1083
  log(' • no managed restart + no --health-url — new core installed but not confirmed serving; restart the instance + health-check it to complete the bump.');
986
1084
  }
987
1085
 
1086
+ // 7b. Confirm the SERVED process actually swapped (task 1002884). Everything above this point
1087
+ // can be true of an instance still running the OLD core: the pin moved, node_modules holds
1088
+ // the new version, and /healthz answers 200 — from the process that never went down. Only
1089
+ // the live coreVersion settles it.
1090
+ // A definite MISMATCH is a failure (same treatment as a failed health check). Being unable
1091
+ // to READ the endpoint is not: an instance may not expose it, and refusing every such bump
1092
+ // would be a worse regression than the false-pass. Passing --version-url explicitly asks
1093
+ // for proof, so there an unreadable endpoint IS a failure.
1094
+ let servedOk = null;
1095
+ let servedVersion = null;
1096
+ const versionUrl = opts.versionUrl || (opts.healthUrl ? deriveVersionUrl(opts.healthUrl) : null);
1097
+ if (opts.healthCheck === false) {
1098
+ // --no-health-check is the documented opt-out from confirming the bump serves; it covers this.
1099
+ } else if (versionUrl && healthOk !== false) {
1100
+ const sv = await pollServedVersion(versionUrl, deps);
1101
+ servedVersion = sv.version || null;
1102
+ if (sv.ok && servedVersion === targetVersion) {
1103
+ servedOk = true;
1104
+ log(` ✓ served core version: ${servedVersion} (${versionUrl})`);
1105
+ } else if (sv.ok) {
1106
+ servedOk = false;
1107
+ err(` ! served core version is ${servedVersion} but the target is ${targetVersion} (${versionUrl})`);
1108
+ err(' The restart did not swap the running process — the old core is still being served.');
1109
+ } else if (opts.versionUrl) {
1110
+ servedOk = false;
1111
+ err(` ! could not read ${versionUrl} (${sv.error}) — --version-url was given, so this bump cannot be confirmed`);
1112
+ } else {
1113
+ err(` ! could not read ${versionUrl} (${sv.error}) — this bump is NOT confirmed against the running process.`);
1114
+ err(' Pass --version-url <url> if this instance serves its version elsewhere.');
1115
+ }
1116
+ }
1117
+
988
1118
  // A failed post-restart health check means the new core is serving broken — auto-revert (task
989
- // 2149) instead of recording an "upgraded" row + leaving the instance down. The rollback writes
990
- // its OWN rolled_back ledger row, so skip the normal insert below.
991
- if (healthOk === false && rollbackOnFailure && snapshot) {
992
- const rb = await doRollback('health check failed after restart');
993
- return { ok: false, error: `health check failed after restart — rolled back to ${snapshot.prevVersion || 'the previous core'}`, fromVersion, toVersion: targetVersion, versionOk, healthOk: false, rolledBack: true, rollback: rb };
1119
+ // 2149) instead of recording an "upgraded" row + leaving the instance down. A served-version
1120
+ // mismatch means the new core is not serving AT ALL (task 1002884) and reverts the same way.
1121
+ // The rollback writes its OWN rolled_back ledger row, so skip the normal insert below.
1122
+ if ((healthOk === false || servedOk === false) && rollbackOnFailure && snapshot) {
1123
+ const why = healthOk === false
1124
+ ? 'health check failed after restart'
1125
+ : `served core version did not change (still ${servedVersion || 'unconfirmed'}, expected ${targetVersion})`;
1126
+ const rb = await doRollback(why);
1127
+ return { ok: false, error: `${why} — rolled back to ${snapshot.prevVersion || 'the previous core'}`, fromVersion, toVersion: targetVersion, versionOk, healthOk, servedOk, servedVersion, rolledBack: true, rollback: rb };
994
1128
  }
995
1129
 
996
1130
  const ledger = await recordLedger({ fromVersion, toVersion: targetVersion, sourceCommit: opts.sourceCommit, note: opts.note }, deps);
@@ -1010,9 +1144,9 @@ async function runUpgrade(opts, deps = {}) {
1010
1144
  dryRun: opts.dryRun,
1011
1145
  }, run, log, err);
1012
1146
 
1013
- const ok = versionOk && healthOk !== false;
1147
+ const ok = versionOk && healthOk !== false && servedOk !== false;
1014
1148
  log(ok ? `\n✓ upgrade complete — core ${fromVersion || '(none)'} → ${targetVersion}` : `\n! upgrade finished with warnings — review above.`);
1015
- return { ok, fromVersion, toVersion: targetVersion, versionOk, healthOk, ledger: ledger.ok, pin };
1149
+ return { ok, fromVersion, toVersion: targetVersion, versionOk, healthOk, servedOk, servedVersion, ledger: ledger.ok, pin };
1016
1150
  }
1017
1151
 
1018
1152
  function parseArgs(argv) {
@@ -1023,6 +1157,7 @@ function parseArgs(argv) {
1023
1157
  service: arg('--service', argv),
1024
1158
  restartCmd: arg('--restart-cmd', argv),
1025
1159
  healthUrl: arg('--health-url', argv),
1160
+ versionUrl: arg('--version-url', argv), // task 1002884: read the LIVE process's coreVersion back; defaults to /version on the health URL's origin
1026
1161
  sourceCommit: arg('--source-commit', argv),
1027
1162
  note: arg('--note', argv),
1028
1163
  pinPath: arg('--pin-path', argv),
@@ -1058,7 +1193,8 @@ async function main(argv = process.argv.slice(2)) {
1058
1193
  ' --service <unit> systemd unit to restart after applying',
1059
1194
  ' --restart-cmd <cmd> explicit restart command (overrides --service)',
1060
1195
  ' --health-url <url> poll after restart (~30s) until 2xx; a still-failing check fails the bump (auto-rollback). REQUIRED for a bump that restarts the service, unless --no-health-check.',
1061
- ' --no-health-check explicitly skip the post-restart health confirmation (default: the check is ON a bump that restarts the service must confirm it serves)',
1196
+ ' --version-url <url> read the RUNNING process\'s coreVersion back after restart and require it to equal --to (default: /version on the --health-url origin). A health check alone cannot tell a new core from an old one that never went down.',
1197
+ ' --no-health-check explicitly skip the post-restart health confirmation (default: the check is ON — a bump that restarts the service must confirm it serves); also skips the served-version read-back',
1062
1198
  ' --skip-migrate do not run `npm run migrate`',
1063
1199
  ' --skip-materialize do not refresh .claude/',
1064
1200
  ' --skip-regen-docs do not regenerate the OpenAPI spec + typed client from the new core',
@@ -1069,8 +1205,8 @@ async function main(argv = process.argv.slice(2)) {
1069
1205
  ' --dry-run show the plan; change nothing',
1070
1206
  ' --force skip the clean-tree + same-version guards',
1071
1207
  '',
1072
- 'Full bump: pre-flight → bump pin → npm install → verify integrity pin → migrate → materialize .claude → regen API docs+client → restart → verify.',
1073
- 'On a failure after the pin moves (install/pin-verify/migrate/health), auto-rollback reverts the pin, reinstalls the previous core, restarts, and records a rolled_back ledger row (the DB is not rolled back — core migrations are additive).',
1208
+ 'Full bump: pre-flight → bump pin → npm install → verify integrity pin → migrate → materialize .claude → regen API docs+client → restart → verify (health + served coreVersion).',
1209
+ 'On a failure after the pin moves (install/pin-verify/migrate/restart/health/served-version), auto-rollback reverts the pin, reinstalls the previous core, restarts, and records a rolled_back ledger row (the DB is not rolled back — core migrations are additive).',
1074
1210
  ].join('\n') + '\n');
1075
1211
  return 0;
1076
1212
  }
@@ -1087,7 +1223,7 @@ async function main(argv = process.argv.slice(2)) {
1087
1223
  module.exports = {
1088
1224
  runUpgrade, parseArgs, main, rollback,
1089
1225
  versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
1090
- vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity, resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth,
1226
+ vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity, resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth, deriveVersionUrl, pollServedVersion,
1091
1227
  readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
1092
1228
  CORE_PKG, ARTIFACT,
1093
1229
  };
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.679'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.681'; // 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');
@@ -554,6 +554,11 @@ test('1003546 every REGENERABLE artifact has a producer, and every producer is i
554
554
  ['docs/repo-map.md', 'scripts/gds/gen-repo-map.js'],
555
555
  ['docs/file-map.md', 'scripts/gds/gen-file-map.js'],
556
556
  ['docs/session-log-index.md', 'scripts/gds/gen-session-index.js'],
557
+ // task 1001431 — the ROOT CLAUDE.md joined REGENERABLE for its §13 snippet block.
558
+ // gen-session-index.js writes BOTH the (gitignored) whole index and that tracked
559
+ // snippet, and it is already in GENERATOR_SCRIPTS, so the server's post-merge
560
+ // regeneration covers the new entry with no change to the generator list.
561
+ ['CLAUDE.md', 'scripts/gds/gen-session-index.js'],
557
562
  [CI.REGISTRY_REL, 'scripts/gds/copy-inventory.js'],
558
563
  [CI.REPORT_REL, 'scripts/gds/copy-inventory.js'],
559
564
  ...G.NESTED_DIRS.map((d) => [`${d}/CLAUDE.md`, 'scripts/gds/gen-repo-map.js']),
@@ -45,6 +45,7 @@ const repo = path.resolve(here, '..');
45
45
  const regen = require('../scripts/gds/git-merge-regen.js');
46
46
  const G = require('../scripts/gds/gen-repo-map.js');
47
47
  const CI = require('../scripts/gds/copy-inventory.js');
48
+ const SI = require('../scripts/gds/gen-session-index.js'); // task 1001431 — the root CLAUDE.md's §13 snippet
48
49
 
49
50
  const MARKERS = /^(<{7}|={7}|>{7})/m;
50
51
 
@@ -120,10 +121,67 @@ function withBlockBody(src, body) {
120
121
 
121
122
  // ── 5) a non-generated path is never in scope ───────────────────────────────────
122
123
  {
123
- assert.equal(regen.REGENERABLE.has('CLAUDE.md'), false, 'top-level CLAUDE.md is hand-written — never auto-healed');
124
124
  assert.equal(regen.REGENERABLE.has('scripts/gds/box.js'), false, 'source files are never auto-healed');
125
125
  const r = regen.regenerateResolved('scripts/gds/box.js', { base: 'a', ours: 'b', theirs: 'c' });
126
126
  assert.equal(r.content, null, 'a non-generated path resolves to null content');
127
+
128
+ // REVERSAL (task 1001431). This block used to assert the opposite —
129
+ // REGENERABLE.has('CLAUDE.md') === false, 'top-level CLAUDE.md is hand-written'
130
+ // — and that was correct when it was written. ADR 0062 §8 then gave the root
131
+ // CLAUDE.md a generated §13 BEGIN/END SESSION-LOG SNIPPET block, which made the
132
+ // premise false without anything noticing: two sessions each adding a session-log
133
+ // file regenerate that block differently and conflict there on EVERY merge, and no
134
+ // layer healed it (not the driver, not ship.js, not the server resolver).
135
+ assert.equal(regen.REGENERABLE.has('CLAUDE.md'), true,
136
+ 'the root CLAUDE.md carries a generated §13 snippet block and IS healed (task 1001431)');
137
+ }
138
+
139
+ // ── 5b) the root CLAUDE.md heals: prose 3-way-merged, snippet re-rendered ────────
140
+ // It is the docs/file-map.md shape (hand-written prose + a generated block), NOT the
141
+ // whole-file shape — so a conflict in the PROSE must still fall through to a human.
142
+ {
143
+ const realRoot = fs.readFileSync(path.join(repo, 'CLAUDE.md'), 'utf8');
144
+ const freshSnippet = SI.renderSnippet(SI.collectEntries());
145
+
146
+ // A conflict confined to the generated block heals, and the block comes out equal to
147
+ // a fresh render regardless of what either side had in it.
148
+ const mangle = (tag) => SI.injectSnippet(realRoot, `${SI.BEGIN}\n- ${tag}\n${SI.END}`);
149
+ const healed = regen.regenerateResolved('CLAUDE.md', {
150
+ base: realRoot, ours: mangle('OURS entry'), theirs: mangle('THEIRS entry'),
151
+ });
152
+ assert.equal(healed.ok, true, 'a conflict inside the generated snippet must heal');
153
+ assert.ok(!MARKERS.test(healed.content), 'no conflict markers survive the heal');
154
+ assert.ok(healed.content.includes(freshSnippet), 'the snippet is re-rendered from the session-log files');
155
+ assert.ok(!healed.content.includes('OURS entry') && !healed.content.includes('THEIRS entry'),
156
+ 'neither side\'s stale snippet content survives');
157
+
158
+ // Hand-written PROSE that genuinely diverges is NOT auto-resolved.
159
+ const firstNL = realRoot.indexOf('\n');
160
+ const proseConflict = regen.regenerateResolved('CLAUDE.md', {
161
+ base: realRoot,
162
+ ours: '# OURS heading' + realRoot.slice(firstNL),
163
+ theirs: '# THEIRS heading' + realRoot.slice(firstNL),
164
+ });
165
+ assert.equal(proseConflict.ok, false, 'a real prose conflict must fall through to a human');
166
+ }
167
+
168
+ // ── 5c) the .gitattributes pattern for the root CLAUDE.md is ROOT-ANCHORED ───────
169
+ // `/CLAUDE.md` vs `CLAUDE.md` is the whole ballgame: a gitattributes pattern with no
170
+ // slash matches at ANY depth, so the bare spelling would also capture the 22
171
+ // modules/*/CLAUDE.md files and scripts/gds/CLAUDE.md — none of which are in
172
+ // NESTED_DIRS, so the driver has no rule for them, exits 1, and FORCES a conflict git
173
+ // would otherwise merge cleanly. Asserted through git itself, not by reading the file,
174
+ // because the pattern semantics are git's and not ours to restate.
175
+ {
176
+ const attr = (p) => {
177
+ const r = spawnSync('git', ['check-attr', 'merge', '--', p], { cwd: repo, encoding: 'utf8' });
178
+ return (r.stdout || '').trim();
179
+ };
180
+ assert.match(attr('CLAUDE.md'), /otb-regen$/, 'the root CLAUDE.md must get the driver');
181
+ for (const p of ['modules/economy/CLAUDE.md', 'scripts/gds/CLAUDE.md']) {
182
+ assert.doesNotMatch(attr(p), /otb-regen/,
183
+ `${p} is not regenerable — the root pattern must be anchored so it is not captured`);
184
+ }
127
185
  }
128
186
 
129
187
  // ── 6) END-TO-END: a real `git merge` invokes the driver and auto-resolves ──────
@@ -193,7 +251,11 @@ function withBlockBody(src, body) {
193
251
  .filter((line) => line && !line.startsWith('#'))
194
252
  .map((line) => line.split(/\s+/))
195
253
  .filter((tokens) => tokens.slice(1).includes('merge=otb-regen'))
196
- .map((tokens) => tokens[0]);
254
+ // Strip a leading `/`: in a gitattributes pattern that is a ROOT ANCHOR, not part of
255
+ // the path (task 1001431 — `/CLAUDE.md` must not also match modules/*/CLAUDE.md). The
256
+ // driver receives git's %P, which never carries it, so REGENERABLE keys are unanchored
257
+ // and the two lists are compared as the PATH sets they are.
258
+ .map((tokens) => tokens[0].replace(/^\//, ''));
197
259
  assert.ok(otbRegenPaths.length > 0, 'expected at least one merge=otb-regen line in .gitattributes');
198
260
  for (const p of otbRegenPaths) {
199
261
  assert.ok(regen.REGENERABLE.has(p), `.gitattributes declares merge=otb-regen for ${p} but REGENERABLE (git-merge-regen.js) does not know it`);
@@ -194,6 +194,63 @@ await t('enableAutoMerge: posts the graphql mutation; false on a graphql error',
194
194
  assert.equal(await gp.enableAutoMerge({ nodeId: null }, { token: 'TT', fetchImpl: okFetch }), false, 'no node id → false');
195
195
  });
196
196
 
197
+ // ---- publishFailedDetail: the generic 500 names its cause (task 1001431) --
198
+ await t('publishFailedDetail: threads the error code so publish_failed is never bare', async () => {
199
+ const D = buildTasksRouter.publishFailedDetail;
200
+
201
+ // The regression this closes: a code with no git_step/stderr used to produce {}, so the
202
+ // builder received `publish_failed` and nothing else — an expired App token and a
203
+ // GraphQL outage were indistinguishable.
204
+ assert.deepEqual(D({ code: 'APP_TOKEN_FAILED' }), { code: 'APP_TOKEN_FAILED' });
205
+ assert.deepEqual(D({ code: 'GRAPHQL_FAILED', detail: '[{"type":"FORBIDDEN"}]' }),
206
+ { code: 'GRAPHQL_FAILED', detail: '[{"type":"FORBIDDEN"}]' });
207
+
208
+ // Existing field names + meanings are unchanged — ship.js prints `reason`.
209
+ assert.deepEqual(
210
+ D({ code: 'GIT_FAILED', git_step: 'push', stderr: 'Permission denied', stderr_bytes: 17, exit_code: 128, timed_out: true }),
211
+ { code: 'GIT_FAILED', step: 'push', reason: 'Permission denied', reason_bytes: 17, git_exit_code: 128, timed_out: true }
212
+ );
213
+
214
+ // Absent fields stay absent rather than serializing as null/undefined keys.
215
+ assert.deepEqual(D({}), {});
216
+ assert.deepEqual(D(), {});
217
+ assert.equal('timed_out' in D({ timed_out: false }), false, 'a falsy timed_out is omitted, not sent as false');
218
+ assert.equal('git_exit_code' in D({ exit_code: 0 }), true, 'exit_code 0 is real and must survive');
219
+ });
220
+
221
+ // ---- enableAutoMergeDetailed: says WHY it could not arm (task 1001431) ----
222
+ await t('enableAutoMergeDetailed: reports the GitHub message instead of a bare false', async () => {
223
+ const okFetch = async () => jsonRes(200, { data: { enablePullRequestAutoMerge: {} } });
224
+ assert.deepEqual(await gp.enableAutoMergeDetailed({ nodeId: 'N1' }, { token: 'TT', fetchImpl: okFetch }),
225
+ { ok: true, reason: null });
226
+
227
+ // The real-world case: a repository with allow_auto_merge OFF. GitHub answers with a
228
+ // plain GraphQL error, and its own message is the actionable one.
229
+ const offFetch = async () => jsonRes(200, { errors: [{ message: 'Auto-merge is not allowed for this repository' }] });
230
+ const off = await gp.enableAutoMergeDetailed({ nodeId: 'N1' }, { token: 'TT', fetchImpl: offFetch });
231
+ assert.equal(off.ok, false);
232
+ assert.match(off.reason, /Auto-merge is not allowed/, 'the repo-level cause reaches the caller');
233
+
234
+ const httpFetch = async () => jsonRes(502, {});
235
+ const http = await gp.enableAutoMergeDetailed({ nodeId: 'N1' }, { token: 'TT', fetchImpl: httpFetch });
236
+ assert.equal(http.ok, false);
237
+ assert.match(http.reason, /HTTP 502/, 'an HTTP failure is distinguishable from a rejection');
238
+
239
+ const thrown = await gp.enableAutoMergeDetailed({ nodeId: 'N1' }, {
240
+ token: 'TT', fetchImpl: async () => { throw new Error('socket hang up'); },
241
+ });
242
+ assert.equal(thrown.ok, false);
243
+ assert.match(thrown.reason, /socket hang up/);
244
+
245
+ assert.equal((await gp.enableAutoMergeDetailed({ nodeId: null }, { token: 'TT', fetchImpl: okFetch })).ok, false);
246
+ assert.match((await gp.enableAutoMergeDetailed({ nodeId: null }, { token: 'TT', fetchImpl: okFetch })).reason,
247
+ /no node id/, 'a missing node id is named, not silently false');
248
+
249
+ // The boolean façade keeps its exact contract for every existing caller.
250
+ assert.equal(await gp.enableAutoMerge({ nodeId: 'N1' }, { token: 'TT', fetchImpl: okFetch }), true);
251
+ assert.equal(await gp.enableAutoMerge({ nodeId: 'N1' }, { token: 'TT', fetchImpl: offFetch }), false);
252
+ });
253
+
197
254
  // ---- getPublishStatus: the three states -----------------------------------
198
255
  await t('getPublishStatus: open PR → pending; merged + deploy success → success', async () => {
199
256
  const mergeSha = 'c'.repeat(40);