@bongos/core 1.19.318 → 1.19.320

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.bongos-core.json +125 -40
  2. package/.claude/skills/merge-mode/SKILL.md +3 -1
  3. package/.devcontainer/init-firewall.sh +41 -5
  4. package/clients/bongos-client/examples/hello-world.mjs +11 -2
  5. package/docs/copy-inventory.md +20 -20
  6. package/docs/copy-registry.json +22 -22
  7. package/modules/hall-ui/public/builders.js +0 -1
  8. package/package-lock.json +2 -2
  9. package/package.json +1 -1
  10. package/scripts/gds/audit-replay.sh +20 -3
  11. package/scripts/gds/box.js +79 -86
  12. package/scripts/gds/db-backup-nightly.sh +17 -4
  13. package/scripts/gds/db-backup-offsite.sh +23 -6
  14. package/scripts/gds/discipline-modes.json +1 -1
  15. package/scripts/gds/doc-cli-guard.js +34 -2
  16. package/scripts/gds/fitness.js +8 -4
  17. package/scripts/gds/gds-db-backup.service +15 -4
  18. package/scripts/gds/gds-db-backup.timer +12 -1
  19. package/scripts/gds/gds-db-offsite.service +16 -5
  20. package/scripts/gds/gds-db-offsite.timer +12 -1
  21. package/scripts/gds/go-live.js +142 -48
  22. package/scripts/gds/instance-apex.js +28 -0
  23. package/scripts/gds/newcomer-floor.templates.json +2 -2
  24. package/scripts/gds/provision.js +7 -3
  25. package/scripts/gds/ship-deploy-target.js +388 -0
  26. package/scripts/gds/ship-finish.js +377 -0
  27. package/scripts/gds/ship-flow.js +814 -0
  28. package/scripts/gds/ship-git.js +100 -0
  29. package/scripts/gds/ship-grade-steps.js +376 -0
  30. package/scripts/gds/ship-grade.js +319 -0
  31. package/scripts/gds/ship-io.js +100 -0
  32. package/scripts/gds/ship-land.js +539 -0
  33. package/scripts/gds/ship-memory-sync.js +192 -0
  34. package/scripts/gds/ship-merge.js +297 -0
  35. package/scripts/gds/ship-preflight-steps.js +323 -0
  36. package/scripts/gds/ship-regen.js +286 -0
  37. package/scripts/gds/ship-resume.js +151 -0
  38. package/scripts/gds/ship-session-upload.js +216 -0
  39. package/scripts/gds/ship-state.js +73 -0
  40. package/scripts/gds/ship-surprise.js +84 -0
  41. package/scripts/gds/ship.js +105 -4195
  42. package/scripts/gds/update-channel.js +12 -0
  43. package/scripts/status-probe.mjs +30 -2
  44. package/src/module-api.js +5 -1
  45. package/tests/go_live.mjs +65 -32
  46. package/tests/helpers.mjs +13 -0
  47. package/tests/provision.mjs +7 -1
  48. package/tests/regrade_eligibility.mjs +43 -0
  49. package/tests/ship_assurance.mjs +4 -2
  50. package/tests/ship_publish_error.mjs +43 -0
  51. package/tests/update_channel.mjs +33 -0
@@ -0,0 +1,323 @@
1
+ // scripts/gds/ship-preflight-steps.js — the preflight gate (tree, freshness, deletions) + pre-verify doc auto-linkify.
2
+ //
3
+ // Carved out of scripts/gds/ship.js by task 1003205 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original ship.js lines 1469-1762 unchanged. ship.js remains the facade that
6
+ // re-exports every name and owns the CLI entry point, so the ship contract and
7
+ // every flag are untouched, and the other scripts that import from it are too.
8
+
9
+ 'use strict';
10
+
11
+ const { execFileSync } = require('node:child_process');
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const { trackedDirty, cleanupMacFinderDupRefs } = require('../../modules/lifecycle/ship-preflight');
15
+ const mergeRegen = require('./git-merge-regen');
16
+ const { argvOut, gitArgvOk, gitOk } = require('./ship-git.js');
17
+ const { pushVia } = require('./ship-deploy-target.js');
18
+ const { computeChangedFiles, computeGraderBaseline } = require('./ship-grade.js');
19
+ const { currentBranch } = require('./ship-regen.js');
20
+ const { BEHIND_MAIN_LIMIT, REPO_ROOT } = require('./ship-state.js');
21
+
22
+ // ---------- Step 0: preflight (working tree + branch freshness) ----------
23
+ //
24
+ // Catches the 2026-05-10 "phantom ship" bug: builder forgot to stage/commit
25
+ // their actual work. The committed-diff scanner reported clean (because nothing
26
+ // was committed), smoke passed, and the task auto-shipped with zero code
27
+ // change. The fix is to inspect the working tree BEFORE the claim resolves and
28
+ // refuse to proceed when any tracked file has uncommitted/staged changes.
29
+ //
30
+ // Also refuses when the branch is far behind origin/main — merging a stale
31
+ // branch is a known regression vector.
32
+
33
+ // parseDeletedUpstreamPaths — pure: turn `git log --diff-filter=D --name-only
34
+ // --pretty=format:` output (one deleted path per line, blank lines between
35
+ // commits) into a deduped, order-preserving list. Exported so preflight's
36
+ // upstream-deletion warning (step (d)) is unit-tested without a live git repo.
37
+ // Null/empty input (no matches, or the git call failed closed) → [].
38
+ function parseDeletedUpstreamPaths(rawLogOutput) {
39
+ if (!rawLogOutput) return [];
40
+ return [...new Set(rawLogOutput.split('\n').map((s) => s.trim()).filter(Boolean))];
41
+ }
42
+
43
+ function preflight(claim, opts = {}) {
44
+ // (a) Working-tree check — refuse if any TRACKED file carries an uncommitted
45
+ // modification or staged change. This fires post-commit, so a clean tree
46
+ // proves the builder's work is actually committed. touches[] cleanup [2/8]
47
+ // (task 877): this replaced the old touches[]-scoped match, which was skipped
48
+ // entirely for empty-touches tasks (leaving them unprotected). The plain
49
+ // clean-tree check is strictly stronger and needs no touches[]. Untracked /
50
+ // .gitignored files are ignored so autonomous scratch files don't false-refuse.
51
+ const porcelain = gitOk('git status --porcelain');
52
+ const dirty = trackedDirty(porcelain);
53
+ if (dirty.length > 0) {
54
+ console.log(' preflight: working tree has uncommitted tracked changes:');
55
+ for (const file of dirty) console.log(` - ${file}`);
56
+ console.log('');
57
+ console.log(' Stage + commit these (or revert them) before shipping.');
58
+ console.log(' This guard exists because uncommitted work has shipped as "complete" before.');
59
+ return { ok: false, reason: 'working-tree-dirty' };
60
+ }
61
+
62
+ // (b) Branch-freshness check — refuse if behind origin/main by too much.
63
+ // Fetch quietly so the comparison is against current remote state. Clean
64
+ // macOS Finder/iCloud `* 2` duplicate refs first or fetch will fail with
65
+ // "bad object refs/heads/<name> 2".
66
+ const cleaned = cleanupMacFinderDupRefs(REPO_ROOT);
67
+ if (cleaned.length > 0) {
68
+ console.log(` preflight: removed ${cleaned.length} stray refs (macOS Finder duplicates)`);
69
+ }
70
+ gitOk('git fetch origin main');
71
+ const behindStr = gitOk('git rev-list --count HEAD..origin/main');
72
+ const behind = behindStr ? Number(behindStr) : 0;
73
+ if (Number.isFinite(behind) && behind > 0) {
74
+ // (b) Branch-freshness is REPORT-ONLY now (#1068). Being behind origin/main
75
+ // is no longer a refusal: shipMain() merges origin/main INTO the branch up
76
+ // front (mergeMainIntoBranch), BEFORE the sandbox review gate, so the tree
77
+ // the human reviews == the tree we ship — killing the post-approval re-stage
78
+ // loop the #1057 retro hit. We still fetch (above) so that merge + the
79
+ // stale-tooling guard below see current remote state.
80
+ const note = behind > BEHIND_MAIN_LIMIT ? ` (well past the ${BEHIND_MAIN_LIMIT}-commit mark)` : '';
81
+ console.log(` preflight: branch is ${behind} commit(s) behind origin/main${note} — shipMain merges it in before staging.`);
82
+ }
83
+
84
+ // (c) Stale ship-tooling guard. The behind-limit above is deliberately lenient
85
+ // so an unrelated commit to main doesn't block every parallel builder — but
86
+ // that means a branch a FEW commits behind sails through, and if those commits
87
+ // touched the ship/grade tooling, the builder runs a STALE ship.js / grader.js
88
+ // / smoke runner and the ship silently fails the same way every time. That was
89
+ // the #662 root cause (a grader.js predating the Windows spawn fix ran twice).
90
+ // So: regardless of commit count, refuse if origin/main carries changes to the
91
+ // tooling that runs the ship locally and this branch lacks them. Three-dot diff
92
+ // (HEAD...origin/main) scopes to changes on the origin/main side since the
93
+ // merge-base, so the builder's OWN edits to these files (e.g. shipping a
94
+ // ship.js fix) do not trip it.
95
+ const TOOLING_PATHS = [
96
+ 'scripts/gds/ship.js',
97
+ 'scripts/gds/smoke-gds.js',
98
+ 'scripts/gds/cli-lib.js',
99
+ 'modules/grading/grader.js', // BV1.R73: grading carved to modules/grading/
100
+ ];
101
+ const staleToolingStr = gitOk(`git diff --name-only HEAD...origin/main -- ${TOOLING_PATHS.join(' ')}`);
102
+ const staleTooling = staleToolingStr ? staleToolingStr.trim().split('\n').filter(Boolean) : [];
103
+ if (staleTooling.length > 0) {
104
+ if (opts.force) {
105
+ console.log(` preflight: ship tooling is behind origin/main (${staleTooling.join(', ')}) — proceeding due to --force`);
106
+ } else {
107
+ console.log(' preflight: your ship/grade tooling is behind origin/main:');
108
+ for (const f of staleTooling) console.log(` - ${f}`);
109
+ console.log(' These files RUN the ship + grader locally — shipping with a stale copy silently fails (root cause of #662).');
110
+ console.log(' Run `git merge origin/main` (or `git pull --rebase origin main`) on this branch, then re-ship. Or --force to override.');
111
+ return { ok: false, reason: 'stale-tooling' };
112
+ }
113
+ }
114
+
115
+ // (d) Upstream-deletion warning (WARN-ONLY — never blocks, never changes the
116
+ // exit code). main can delete or relocate a surface between when a task was
117
+ // claimed and when it ships (a cutover, a carve, a rename — the R89 hall-ui
118
+ // cutover class); the branch's own diff still touches the OLD path, and an
119
+ // oblivious ship can silently revert the upstream deletion or leave this
120
+ // branch's work orphaned. Scoped to the paths THIS branch's diff actually
121
+ // touches — the same baseline/diff computeGraderBaseline/computeChangedFiles
122
+ // compute for the grader — so an unrelated upstream deletion elsewhere in the
123
+ // repo never fires it. Silent no-op if origin/main isn't resolvable (offline /
124
+ // never fetched) or this branch's diff is empty — the check itself must never
125
+ // be why a ship breaks. parseDeletedUpstreamPaths is the pure parsing seam,
126
+ // exported + unit-tested (tests/ship_upstream_deletion.mjs) without a live git
127
+ // repo; this block is just the git I/O + presentation around it.
128
+ const upstreamDelBaseline = computeGraderBaseline(claim);
129
+ const upstreamDelTouched = upstreamDelBaseline ? computeChangedFiles(upstreamDelBaseline) : [];
130
+ if (upstreamDelTouched.length > 0) {
131
+ const deletedUpstreamStr = argvOut('git', [
132
+ 'log', 'HEAD..origin/main', '--diff-filter=D', '--name-only', '--pretty=format:',
133
+ '--', ...upstreamDelTouched,
134
+ ]);
135
+ const deletedUpstream = parseDeletedUpstreamPaths(deletedUpstreamStr);
136
+ if (deletedUpstream.length > 0) {
137
+ console.error('');
138
+ console.error(`⚠ Upstream deletion warning: main has removed ${deletedUpstream.length} path(s) this branch still touches, since this task was claimed:`);
139
+ for (const f of deletedUpstream) console.error(` - ${f}`);
140
+ console.error(' This looks like the R89 hall-ui cutover class: a surface removed or relocated upstream while the claim was in flight.');
141
+ console.error(' Verify these paths still exist on main (or that the removal/rename is intentional) before shipping —');
142
+ console.error(" shipping anyway may silently revert the upstream deletion or leave this branch's work orphaned.");
143
+ console.error(' Warning only — not blocking; the ship proceeds.');
144
+ console.error('');
145
+ }
146
+ }
147
+
148
+ console.log(` preflight: working tree clean, branch ${behind} commits behind main, ship tooling current — OK`);
149
+ return { ok: true };
150
+ }
151
+
152
+ // mergeMainIntoBranch — pull origin/main INTO the current feature branch BEFORE
153
+ // the sandbox review gate (#1068, gap 2), so the tree the human reviews == the
154
+ // tree we ship and an --approved lands in ONE shot. This subsumes the old
155
+ // behind-main hard refusal: instead of telling the builder to merge by hand
156
+ // (which then invalidated their just-given sandbox approval — the #1057 retro),
157
+ // we do it up front. Aborts ONLY on a genuine merge conflict (the human must
158
+ // resolve those); a no-op when already current or not on a feature branch. Runs
159
+ // in the session's worktree (currentBranch()/gitOk operate on process.cwd()).
160
+ // Returns { ok, merged, conflict?, reason? }.
161
+ // decidePreMerge — the PURE policy for the pre-stage merge: skip on main/detached
162
+ // HEAD, on the server-publish path, or when already current; otherwise merge.
163
+ // Exported so the truth table is unit-tested with no git. `via` is pushVia()'s
164
+ // verdict ('server' | 'local' | undefined). Returns { action:'skip'|'merge', reason }.
165
+ function decidePreMerge({ branch, behind, via }) {
166
+ if (!branch || branch === 'main' || branch === 'HEAD') {
167
+ return { action: 'skip', reason: `not on a feature branch (${branch})` };
168
+ }
169
+ // Server-mediated publish (ADR 0055): a credential-less partial-clone dev box
170
+ // (blob:none, HTTPS origin, no gh/credential-helper — ADR 0053) CANNOT run a
171
+ // local `git merge origin/main` — the ort merge lazily fetches main's blobs
172
+ // from the promisor remote and there are no credentials to authenticate that
173
+ // fetch, so the box strands here and can never ship while behind main, i.e.
174
+ // ≈always (idea #364). It also does not NEED to: ciLandServer bundles
175
+ // `origin/main..HEAD` and the SERVER reconciles the branch with the real main
176
+ // when it pushes + opens + auto-merges the PR; a genuine conflict surfaces
177
+ // server-side as merge_conflict (publish-reconciler / conflict-resolve,
178
+ // ADR 0082). The local pre-merge (#1068) is a LAPTOP-path optimization (so the
179
+ // sandbox-reviewed tree == the shipped tree); it stays exactly as-is there
180
+ // (via 'local'/undefined) and is simply skipped on the box.
181
+ if (via === 'server') {
182
+ return {
183
+ action: 'skip',
184
+ reason: 'server-mediated publish reconciles with origin/main server-side (credential-less partial-clone box cannot fetch main blobs locally — idea #364)',
185
+ };
186
+ }
187
+ if (!Number.isFinite(behind) || behind === 0) {
188
+ return { action: 'skip', reason: 'already up to date with origin/main' };
189
+ }
190
+ return { action: 'merge', reason: `${behind} commit(s) behind origin/main` };
191
+ }
192
+
193
+ function mergeMainIntoBranch() {
194
+ const branch = currentBranch();
195
+ // preflight already fetched origin/main + cleaned stray refs; reuse that state.
196
+ const behindStr = gitOk('git rev-list --count HEAD..origin/main');
197
+ const behind = behindStr ? Number(behindStr) : 0;
198
+ const decision = decidePreMerge({ branch, behind, via: pushVia() });
199
+ if (decision.action === 'skip') {
200
+ return { ok: true, merged: false, reason: decision.reason };
201
+ }
202
+ console.log(` pre-stage: merging origin/main into ${branch} (${behind} commit(s) behind) so the reviewed tree == the shipped tree...`);
203
+ const msg = `Merge origin/main into ${branch} (pre-stage freshness, task 1068)`;
204
+ if (gitArgvOk(['merge', '--no-edit', '-m', msg, 'origin/main'])) {
205
+ console.log(' pre-stage: merged origin/main cleanly.');
206
+ return { ok: true, merged: true };
207
+ }
208
+ // Conflict (or other merge failure). Before giving up, AUTO-HEAL conflicts that
209
+ // are confined to GENERATED files (task 1243): docs/repo-map.md + the nested
210
+ // CLAUDE.md blocks reliably collide between parallel branches (they're rebuilt
211
+ // from code, so two branches render them differently), and re-resolving them by
212
+ // hand — three times — was the PR #167 churn. Regenerate them from the merged
213
+ // tree, stage, and complete the merge. This is the guaranteed-to-run safety net
214
+ // that backstops the `otb-regen` git merge driver (install-git-hooks.js), which
215
+ // only fires where it's been registered per-clone.
216
+ const conflicted = (gitOk('git diff --name-only --diff-filter=U') || '').trim();
217
+ const files = conflicted ? conflicted.split('\n').map((s) => s.trim()).filter(Boolean) : [];
218
+ if (files.length && files.every((f) => mergeRegen.REGENERABLE.has(f))) {
219
+ const healed = files.every((f) => mergeRegen.resolveInWorktree(f));
220
+ if (healed && gitArgvOk(['add', ...files]) && gitArgvOk(['commit', '--no-edit'])) {
221
+ console.log(` pre-stage: auto-healed generated-file conflict(s) by regenerating ${files.join(', ')} from the merged tree.`);
222
+ return { ok: true, merged: true, healed: files };
223
+ }
224
+ // A hand-written conflict remained (resolveInWorktree returned false) or the
225
+ // commit failed — fall through to the clean abort below; no half-merged tree.
226
+ }
227
+ // Abort so the tree is left clean, and surface it — we must NOT ship a
228
+ // half-merged tree.
229
+ gitArgvOk(['merge', '--abort']);
230
+ return {
231
+ ok: false,
232
+ merged: false,
233
+ conflict: true,
234
+ reason: conflicted
235
+ ? `merge conflict with origin/main in: ${files.join(', ')}. Resolve on this branch (git merge origin/main), commit, then re-ship`
236
+ : 'could not merge origin/main into this branch (see git output). Resolve manually, then re-ship',
237
+ };
238
+ }
239
+
240
+ // ---------- Pre-verify: auto-linkify the ship's own changed docs ----------
241
+ //
242
+ // The doc-ref-linkify CI gate (tests/linkify_check.mjs, in the required `unit`
243
+ // check — task 1002639; a standalone doc-ref-linkify.yml workflow never
244
+ // actually existed) red-Xes a PR carrying a bare "#NNN" task ref in a doc. A
245
+ // red-X after the builder walked away still strands the land (#747).
246
+ //
247
+ // The structural cure is to fix the ship's OWN changed docs here, before the
248
+ // merge, and commit the rewrite. The gate then stays as the backstop for refs
249
+ // that arrive by other paths (generated docs, direct pushes) but stops firing
250
+ // in the normal /builder-ship path.
251
+ //
252
+ // Scope is deliberately the docs already in THIS commit's diff: rewriting only
253
+ // files the builder already changed means it can NEVER rewrite a doc they
254
+ // didn't touch, and a pre-existing bare ref elsewhere is left for
255
+ // the periodic backlog sweep (`linkify-refs.js --apply`) + the CI gate.
256
+
257
+ // Pure: which changed paths are in the linkifier's domain (the hand-authored
258
+ // markdown a reader sees on github.com — root CLAUDE.md/README.md + docs/**).
259
+ // Mirrors linkify-refs.js discoverDocs() roots. Exported for unit tests.
260
+ function isLinkifiableDocPath(rel) {
261
+ return (
262
+ typeof rel === 'string' &&
263
+ rel.endsWith('.md') &&
264
+ (rel === 'CLAUDE.md' || rel === 'README.md' || rel.startsWith('docs/'))
265
+ );
266
+ }
267
+
268
+ function autoLinkifyChangedDocs(ac) {
269
+ const baseline = computeGraderBaseline(ac);
270
+ let docs = computeChangedFiles(baseline).filter(isLinkifiableDocPath);
271
+ // Mirror discoverDocs()'s GENERATED-FILE skip — an explicit file set bypasses
272
+ // it, and rewriting a generated doc just churns until its next regen.
273
+ docs = docs.filter((rel) => {
274
+ try {
275
+ const head = fs.readFileSync(path.resolve(process.cwd(), rel), 'utf8').slice(0, 200);
276
+ return !/GENERATED FILE/i.test(head);
277
+ } catch (_) {
278
+ return false;
279
+ }
280
+ });
281
+ if (docs.length === 0) return;
282
+
283
+ console.log(`Auto-linkify: scanning ${docs.length} changed doc(s) for bare "#NNN" task refs...`);
284
+ const linkifyScript = path.resolve(__dirname, 'linkify-refs.js');
285
+ try {
286
+ // --apply (no --check) throws on an unreachable id allow-list; treat any
287
+ // failure as best-effort and continue — the CI gate is the backstop, a
288
+ // network blip must never block a ship.
289
+ execFileSync(process.execPath, [linkifyScript, '--apply', ...docs], {
290
+ stdio: ['ignore', 'pipe', 'pipe'],
291
+ cwd: process.cwd(),
292
+ });
293
+ } catch (err) {
294
+ const line = (err.message || 'unknown error').split('\n')[0];
295
+ console.log(` auto-linkify: skipped (linkifier unavailable: ${line}). CI gate remains the backstop.`);
296
+ return;
297
+ }
298
+
299
+ // Commit only if the rewrite actually changed something. status --porcelain
300
+ // over the explicit doc set, via argv (paths can't be spliced into a shell).
301
+ const dirty = gitOk(`git status --porcelain -- ${docs.map((d) => JSON.stringify(d)).join(' ')}`);
302
+ if (!dirty) {
303
+ console.log(' auto-linkify: changed docs already clean — nothing to commit.');
304
+ return;
305
+ }
306
+ gitArgvOk(['add', '--', ...docs]);
307
+ const committed = gitArgvOk(['commit', '-m', 'chore(docs): auto-linkify task refs before ship (task 747)']);
308
+ if (committed) {
309
+ const n = dirty.split('\n').filter(Boolean).length;
310
+ console.log(` auto-linkify: rewrote bare task refs in ${n} doc(s) and committed before merge.`);
311
+ } else {
312
+ console.log(' auto-linkify: commit failed; continuing (CI gate is the backstop).');
313
+ }
314
+ }
315
+
316
+ module.exports = {
317
+ autoLinkifyChangedDocs,
318
+ decidePreMerge,
319
+ isLinkifiableDocPath,
320
+ mergeMainIntoBranch,
321
+ parseDeletedUpstreamPaths,
322
+ preflight,
323
+ };
@@ -0,0 +1,286 @@
1
+ // scripts/gds/ship-regen.js — regeneration of the generated artifacts a ship must keep fresh + the merge-worktree lock.
2
+ //
3
+ // Carved out of scripts/gds/ship.js by task 1003205 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original ship.js lines 2050-2306 unchanged. ship.js remains the facade that
6
+ // re-exports every name and owns the CLI entry point, so the ship contract and
7
+ // every flag are untouched, and the other scripts that import from it are too.
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const { gitOk, shellOk } = require('./ship-git.js');
14
+ const { pushVia } = require('./ship-deploy-target.js');
15
+ const { REPO_ROOT } = require('./ship-state.js');
16
+
17
+ // ---------- Step 3: merge + deploy chain ----------
18
+
19
+ function currentBranch() {
20
+ return gitOk('git rev-parse --abbrev-ref HEAD');
21
+ }
22
+
23
+ // Task #357: keep the 3 mechanical onboarding diagrams current on every deploy.
24
+ // Regenerate them from live facts; if any changed, commit the refresh on this
25
+ // branch so it ships with the deploy. Non-fatal — a render/network hiccup must
26
+ // never block a ship; the diagram-drift routine will flag any miss.
27
+ //
28
+ // Returns a status string ('skipped-server' | 'regen-failed' | 'current' |
29
+ // 'committed' | 'committed-nofatal') so the regression test (task 1046) can
30
+ // prove the dev-box skip without git or a headless browser.
31
+ function regenerateDiagrams() {
32
+ // task 1046: on the server-mediated-publish path (a dev box — pushVia 'server'),
33
+ // there is no headless browser, so render-diagrams.sh fails and gen-diagrams.js
34
+ // exits non-zero AFTER writing the regenerated .mmd + assertions.json — which
35
+ // shellOk() then treats as a failed regen and returns BEFORE the commit below,
36
+ // leaving those files UNCOMMITTED in the builder's tree (had to be reverted by
37
+ // hand). The box must not attempt the regen at all: skip here and let a
38
+ // render-capable ship (the laptop path, which CAN render the PNGs) refresh them
39
+ // as a matched .mmd+.png+assertions set; the diagram-drift routine backstops any
40
+ // staleness in the meantime. Skipping entirely (vs --no-render) avoids committing
41
+ // a .mmd/.png mismatch to main. Mirrors gen-diagrams.js's own non-fatal skip when
42
+ // bash itself is absent (ENOENT).
43
+ if (pushVia() === 'server') {
44
+ console.log(' diagram regen skipped on the dev-box ship path (no GDS session for the conceptual diagrams) — a full-clone ship refreshes the .mmd; drift checker backstops staleness');
45
+ return 'skipped-server';
46
+ }
47
+ // ADR 0110 pt.3 (task 2027): the .svg/.png RENDERS are now gitignored and
48
+ // regenerated at deploy (deploy-prod.sh) — no longer committed here — so ship
49
+ // only refreshes the TRACKED sources. Run gen-diagrams with --no-render: it
50
+ // rewrites the mechanical .mmd + assertions.json from live facts WITHOUT
51
+ // invoking the headless-Chromium render, which is why this no longer needs a
52
+ // render-capable machine. The gitignored .svg/.png never appear in the git add
53
+ // below (a directory add silently skips ignored paths).
54
+ console.log(' regenerating onboarding diagram SOURCES from live facts (--no-render; renders are a deploy artifact, ADR 0110 pt.3)...');
55
+ // process.execPath (quoted) instead of bare `node`: on Windows cmd.exe can't
56
+ // resolve a bare `node` without a full PATH, the same failure #660 fixed for
57
+ // the smoke-gds spawn. The quotes survive spaces in a Windows install path
58
+ // (e.g. C:\Program Files\nodejs\node.exe).
59
+ if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-diagrams.js'))} --no-render`)) {
60
+ console.log(' diagram regen failed (non-fatal) — drift checker will flag any staleness');
61
+ return 'regen-failed';
62
+ }
63
+ const dirty = gitOk('git status --porcelain docs/onboarding/diagrams');
64
+ if (!dirty) {
65
+ console.log(' diagram sources already current — nothing to commit');
66
+ return 'current';
67
+ }
68
+ shellOk('git add docs/onboarding/diagrams');
69
+ if (shellOk('git commit -m "chore(diagrams): auto-regenerate diagram sources from live facts (task 357)"')) {
70
+ console.log(' committed auto-regenerated diagrams');
71
+ return 'committed';
72
+ }
73
+ return 'committed-nofatal';
74
+ }
75
+
76
+ // task 1224 (Path B / ADR 0063): refresh the generated symbol skeleton from the shipped tree so the
77
+ // altitude map can't drift. Pure JS (no headless browser, no render) — unlike the diagrams it is safe
78
+ // on EVERY ship path, including the dev-box server-publish path. Non-fatal: a hiccup must never block
79
+ // a ship. ADR 0110 (task 1926): docs/repo-map.md is now GITIGNORED + regenerated at deploy — the
80
+ // generator still WRITES it (untracked, harmless), but ship commits ONLY the tracked nested-doc
81
+ // blocks. It is deliberately absent from the git add/status lists below: `git add <ignored path>`
82
+ // ABORTS the whole command (exit 1), which would drop the nested-block commit. The block bucket is
83
+ // backstopped on main by fitness.js (`gen-repo-map --check-block`).
84
+ function regenerateRepoMap() {
85
+ console.log(' regenerating code symbol skeleton (repo map + nested-doc blocks) from the shipped tree...');
86
+ if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-repo-map.js'))}`)) {
87
+ console.log(' repo-map regen failed (non-fatal) — CI --check-block backstops the tracked blocks');
88
+ return 'regen-failed';
89
+ }
90
+ const dirty = gitOk('git status --porcelain src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md scripts/gds/CLAUDE.md');
91
+ if (!dirty) {
92
+ console.log(' nested-doc symbol blocks already current — nothing to commit (docs/repo-map.md is gitignored, ADR 0110)');
93
+ return 'current';
94
+ }
95
+ shellOk('git add src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md scripts/gds/CLAUDE.md');
96
+ if (shellOk('git commit -m "chore(repo-map): auto-regenerate nested-doc symbol blocks (task 1224)"')) {
97
+ console.log(' committed auto-regenerated nested-doc symbol blocks');
98
+ return 'committed';
99
+ }
100
+ return 'committed-nofatal';
101
+ }
102
+
103
+ // Refresh the generated session-log index (docs/session-log-index.md) + the bounded CLAUDE.md §13
104
+ // "recent sessions" snippet from the shipped tree's docs/session-logs/*.md (ADR 0062 §8, task 1226).
105
+ // Pure JS (reads files, no DB, no headless browser) — safe on EVERY ship path including the dev-box
106
+ // server-publish path. Non-fatal: a hiccup must never block a ship; the Path-C CI gate
107
+ // (`gen-session-index --check`) backstops staleness on main.
108
+ // Refresh the generated API docs (docs/api/openapi.json + docs/api-reference.md +
109
+ // docs/routes-permissions.md) from the shipped route tree (task 1918). Pure JS (reads
110
+ // route files, no DB, no headless browser) — safe on EVERY ship path incl. the dev-box
111
+ // server-publish path. Non-fatal: a hiccup must never block a ship; the fitness
112
+ // `gen-api-docs --check` gate backstops staleness on main.
113
+ function regenerateApiDocs() {
114
+ console.log(' regenerating OpenAPI spec + API reference from the shipped route tree...');
115
+ if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-api-docs.js'))}`)) {
116
+ console.log(' api-docs regen failed (non-fatal) — CI --check backstops staleness');
117
+ return 'regen-failed';
118
+ }
119
+ const dirty = gitOk('git status --porcelain docs/api/openapi.json docs/api-reference.md docs/routes-permissions.md');
120
+ if (!dirty) {
121
+ console.log(' API docs already current — nothing to commit');
122
+ return 'current';
123
+ }
124
+ shellOk('git add docs/api/openapi.json docs/api-reference.md docs/routes-permissions.md');
125
+ if (shellOk('git commit -m "chore(api-docs): auto-regenerate OpenAPI spec + reference (task 1918)"')) {
126
+ console.log(' committed auto-regenerated API docs');
127
+ return 'committed';
128
+ }
129
+ return 'committed-nofatal';
130
+ }
131
+
132
+ // Refresh the generated typed client (clients/bongos-client) FROM the just-regenerated
133
+ // spec, then keep the devbox vendored CJS copy in lockstep (task 2051). Runs right
134
+ // after regenerateApiDocs so the client reflects the same route tree. Automatic here
135
+ // (not fail-if-forgotten): before this, ship regenerated the spec but not the client,
136
+ // so a route change left the client stale until the api_client test caught it in CI.
137
+ // Non-fatal + safe on every ship path; the api_client freshness test backstops main.
138
+
139
+ // Where the devbox Electron app vendors the CJS client (ADR 0045). It lives inside the
140
+ // dev-box MODULE (ADR 0145) — an instance that ships without that module has no copy.
141
+ const VENDORED_CLIENT_REL = 'modules/dev-box/app/src/vendor/bongos-client.cjs';
142
+
143
+ // The git pathspec for the regenerated client. Pure + exported so the rule is unit-tested
144
+ // with no git/fs (task 1002417). `git status` tolerates an unmatched pathspec (exit 0) but
145
+ // `git add` aborts the ENTIRE staging step (exit 128), which leaves the regenerated client
146
+ // unstaged and the worktree dirty — and a dirty worktree makes the ship's auto-merge bail,
147
+ // stranding the task at `confirmed` with no PR. So the vendored copy is named ONLY when it
148
+ // is actually on disk.
149
+ function apiClientPathspec({ worktreeRoot, existsSync = fs.existsSync } = {}) {
150
+ const paths = ['clients/bongos-client'];
151
+ if (existsSync(path.join(worktreeRoot, VENDORED_CLIENT_REL))) paths.push(VENDORED_CLIENT_REL);
152
+ return paths;
153
+ }
154
+
155
+ function regenerateApiClient() {
156
+ console.log(' regenerating typed API client (clients/bongos-client) from the OpenAPI spec...');
157
+ if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-api-client.js'))}`)) {
158
+ console.log(' api-client regen failed (non-fatal) — CI api_client freshness test backstops staleness');
159
+ return 'regen-failed';
160
+ }
161
+ // The devbox Electron app can't require() clients/ at runtime, so it vendors the CJS
162
+ // build (ADR 0045); keep the copy byte-identical (the api_client test asserts it).
163
+ // Present only where the dev-box module ships — an instance without it has no vendored
164
+ // copy, so both the vendor step and the git pathspec below drop out (task 1002417: the
165
+ // app moved from top-level devbox-app/ to modules/dev-box/app/ per ADR 0145, and these
166
+ // paths were not updated, so the guard below silently never fired).
167
+ // Resolve it WORKTREE-LOCALLY (like the generator above, __dirname=scripts/gds), NOT
168
+ // via REPO_ROOT: on a ship from a linked worktree REPO_ROOT is the MAIN checkout, so a
169
+ // REPO_ROOT path would vendor the main tree while gen-api-client just wrote THIS tree,
170
+ // leaving this worktree's vendored copy stale → the api_client byte-identity test reds
171
+ // the PR (task 2060 fix). __dirname keeps the generator, the vendored file, and the
172
+ // git add all on the same tree.
173
+ const worktreeRoot = path.join(__dirname, '..', '..');
174
+ const vendorScript = path.join(worktreeRoot, 'modules', 'dev-box', 'app', 'scripts', 'vendor-client.js');
175
+ if (fs.existsSync(vendorScript)) {
176
+ shellOk(`"${process.execPath}" ${JSON.stringify(vendorScript)}`);
177
+ }
178
+ const clientPathspec = apiClientPathspec({ worktreeRoot }).join(' ');
179
+ const dirty = gitOk(`git status --porcelain ${clientPathspec}`);
180
+ if (!dirty) {
181
+ console.log(' API client already current — nothing to commit');
182
+ return 'current';
183
+ }
184
+ shellOk(`git add ${clientPathspec}`);
185
+ if (shellOk('git commit -m "chore(api-client): auto-regenerate typed client + devbox vendor (task 2051)"')) {
186
+ console.log(' committed auto-regenerated API client');
187
+ return 'committed';
188
+ }
189
+ return 'committed-nofatal';
190
+ }
191
+
192
+ // ADR 0110 (task 1926): docs/session-log-index.md is GITIGNORED + deploy-regenerated — the generator
193
+ // still writes it (untracked), but ship commits ONLY the tracked CLAUDE.md §13 snippet. The index is
194
+ // omitted from the git add/status lists (an explicit `git add` of an ignored path aborts the commit);
195
+ // the tracked snippet is backstopped on main by fitness.js (`gen-session-index --check-block`).
196
+ function regenerateSessionIndex() {
197
+ console.log(' regenerating session-log index + CLAUDE.md snippet from the shipped tree...');
198
+ if (!shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-session-index.js'))}`)) {
199
+ console.log(' session-index regen failed (non-fatal) — CI --check-block backstops the tracked snippet');
200
+ return 'regen-failed';
201
+ }
202
+ const dirty = gitOk('git status --porcelain CLAUDE.md');
203
+ if (!dirty) {
204
+ console.log(' CLAUDE.md §13 snippet already current — nothing to commit (docs/session-log-index.md is gitignored, ADR 0110)');
205
+ return 'current';
206
+ }
207
+ shellOk('git add CLAUDE.md');
208
+ if (shellOk('git commit -m "chore(session-log): auto-regenerate CLAUDE.md §13 snippet (task 1226)"')) {
209
+ console.log(' committed auto-regenerated CLAUDE.md §13 snippet');
210
+ return 'committed';
211
+ }
212
+ return 'committed-nofatal';
213
+ }
214
+
215
+ // Refresh the generated file-map sections (.claude/skills/ + .claude/scheduled-tasks/ trees in
216
+ // docs/file-map.md) from what is on disk (ADR 0066, task 1276). Pure JS (reads dirs + the
217
+ // docs/file-map.notes.json sidecar, no DB, no headless browser) — safe on EVERY ship path including
218
+ // the dev-box server-publish path. Non-fatal: a hiccup must never block a ship; the Path-C CI gate
219
+ // (`gen-file-map --check`, in fitness.js) backstops staleness on main. NB gen-file-map exits 2 when
220
+ // a skill/routine has no note, so shellOk may report "failed" on a legit note-gap ship — that is
221
+ // fine, we still commit whatever it wrote and CI surfaces the missing note.
222
+ function regenerateFileMap() {
223
+ console.log(' regenerating file-map sections (skills + scheduled-tasks) from the shipped tree...');
224
+ shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'gen-file-map.js'))}`);
225
+ const dirty = gitOk('git status --porcelain docs/file-map.md');
226
+ if (!dirty) {
227
+ console.log(' file-map already current — nothing to commit');
228
+ return 'current';
229
+ }
230
+ shellOk('git add docs/file-map.md');
231
+ if (shellOk('git commit -m "chore(file-map): auto-regenerate skills + scheduled-tasks sections (task 1276)"')) {
232
+ console.log(' committed auto-regenerated file-map sections');
233
+ return 'committed';
234
+ }
235
+ return 'committed-nofatal';
236
+ }
237
+
238
+ // ---------- merge serialization lock (#462 C4; extended in #269 C6) ----------
239
+ //
240
+ // autoMerge() mutates the SHARED main worktree (REPO_ROOT). Two concurrent
241
+ // ships — or a ship racing a /merge-mode in another session — collide on that
242
+ // single working tree. Observed 2026-05-30: a parallel session was mid-merge
243
+ // in REPO_ROOT, so this ship's `git merge --ff-only` failed and it bailed with
244
+ // the misleading "main is ahead of origin somehow".
245
+ //
246
+ // The lock primitives now live in src/bongos/merge-lock.js (V3.R50 / #269) so BOTH
247
+ // ship.js AND the /merge-mode CLI take the SAME mutex — closing the
248
+ // ship-vs-merge-mode and merge-mode-vs-merge-mode races that the in-ship.js
249
+ // lock could not cover. See docs/recipes/multi-builder-merge.md.
250
+
251
+ // REPO_ROOT must be on main, not mid-merge, and clean before we mutate it.
252
+ // Returns a human reason string if busy, or null if safe to proceed.
253
+ function mainWorktreeBusy() {
254
+ if (gitOk('git rev-parse -q --verify MERGE_HEAD', { cwd: REPO_ROOT })) {
255
+ return 'a merge is already in progress';
256
+ }
257
+ const dirty = gitOk('git status --porcelain', { cwd: REPO_ROOT });
258
+ if (dirty && dirty.trim().length > 0) {
259
+ return 'the working tree is dirty or has unmerged files';
260
+ }
261
+ return null;
262
+ }
263
+
264
+ // task 391 (idea #37): the verdict for a ship whose branch has no commits ahead
265
+ // of main. Pure, so the gate is unit-tested.
266
+ // 'has-commits' → normal path: there is a real diff to merge + deploy. (Also
267
+ // the value when `git rev-list` failed/returned null — same as
268
+ // the original `=== '0'` check, which only short-circuited an
269
+ // exact '0'.)
270
+ // 'db-only' → zero commits + explicit --db-only: a deliberate direct-SQL
271
+ // ship (work already live in the prod DB); short-circuit to shipped.
272
+ // 'refuse' → zero commits + NO --db-only: almost always a mistake
273
+ // (uncommitted work / wrong branch / empty touches[]); refuse.
274
+
275
+ module.exports = {
276
+ VENDORED_CLIENT_REL,
277
+ apiClientPathspec,
278
+ currentBranch,
279
+ mainWorktreeBusy,
280
+ regenerateApiClient,
281
+ regenerateApiDocs,
282
+ regenerateDiagrams,
283
+ regenerateFileMap,
284
+ regenerateRepoMap,
285
+ regenerateSessionIndex,
286
+ };